Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled

Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View File

@@ -107,6 +107,7 @@ impl From<&EntropykPicardConfig> for entropyk_solver::PicardConfig {
/// Configuration for the fallback solver (Newton → Picard).
#[repr(C)]
#[derive(Default)]
pub struct EntropykFallbackConfig {
/// Newton solver configuration.
pub newton: EntropykNewtonConfig,
@@ -114,15 +115,6 @@ pub struct EntropykFallbackConfig {
pub picard: EntropykPicardConfig,
}
impl Default for EntropykFallbackConfig {
fn default() -> Self {
Self {
newton: EntropykNewtonConfig::default(),
picard: EntropykPicardConfig::default(),
}
}
}
/// Opaque handle to a solver result.
///
/// Create via `entropyk_solve_*()` functions.

View File

@@ -28,12 +28,11 @@ pub fn load_model() -> Result<(String, String), String> {
let model_path = env::var("ENTROPYK_FMU_MODEL")
.map_err(|_| "no embedded model and ENTROPYK_FMU_MODEL env var not set".to_string())?;
let io_path = env::var("ENTROPYK_FMU_IO")
.map_err(|_| "ENTROPYK_FMU_IO env var not set".to_string())?;
let io_path =
env::var("ENTROPYK_FMU_IO").map_err(|_| "ENTROPYK_FMU_IO env var not set".to_string())?;
let model = std::fs::read_to_string(&model_path)
.map_err(|e| format!("read {model_path}: {e}"))?;
let io = std::fs::read_to_string(&io_path)
.map_err(|e| format!("read {io_path}: {e}"))?;
let model =
std::fs::read_to_string(&model_path).map_err(|e| format!("read {model_path}: {e}"))?;
let io = std::fs::read_to_string(&io_path).map_err(|e| format!("read {io_path}: {e}"))?;
Ok((model, io))
}

View File

@@ -350,4 +350,3 @@ pub unsafe extern "C" fn fmi2GetLastError() -> fmi2String {
None => std::ptr::null(),
}
}

View File

@@ -24,6 +24,8 @@
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(non_upper_case_globals)]
#![allow(clippy::missing_safety_doc)]
#![allow(clippy::const_is_empty)]
pub mod embedded;
pub mod fmi2;

View File

@@ -58,8 +58,8 @@ impl FmuInstance {
/// Parse the model JSON and the IO-map JSON. Both are bundled inside the
/// `.fmu` (model under `resources/`, IO-map under `resources/fmu_io.json`).
pub fn new(config_json: &str, io_json: &str) -> Result<Self, String> {
let config = ScenarioConfig::from_json(config_json)
.map_err(|e| format!("model JSON: {e}"))?;
let config =
ScenarioConfig::from_json(config_json).map_err(|e| format!("model JSON: {e}"))?;
let io: FmuIoSpec =
serde_json::from_str(io_json).map_err(|e| format!("IO-map JSON: {e}"))?;
@@ -201,10 +201,12 @@ fn extract_output(result: &SimulationResult, out: &IoOutput) -> f64 {
"q_cooling_kw" => perf.and_then(|p| p.q_cooling_kw).unwrap_or(f64::NAN),
"q_heating_kw" => perf.and_then(|p| p.q_heating_kw).unwrap_or(f64::NAN),
"compressor_power_kw" => perf.and_then(|p| p.compressor_power_kw).unwrap_or(f64::NAN),
"pressure_bar" => find_edge(result, out.edge).map(|e| e.pressure_bar).unwrap_or(f64::NAN),
"enthalpy_kj_kg" => {
find_edge(result, out.edge).map(|e| e.enthalpy_kj_kg).unwrap_or(f64::NAN)
}
"pressure_bar" => find_edge(result, out.edge)
.map(|e| e.pressure_bar)
.unwrap_or(f64::NAN),
"enthalpy_kj_kg" => find_edge(result, out.edge)
.map(|e| e.enthalpy_kj_kg)
.unwrap_or(f64::NAN),
"mass_flow_kg_s" => find_edge(result, out.edge)
.and_then(|e| e.mass_flow_kg_s)
.unwrap_or(f64::NAN),
@@ -215,7 +217,10 @@ fn extract_output(result: &SimulationResult, out: &IoOutput) -> f64 {
}
}
fn find_edge<'a>(result: &'a SimulationResult, edge: Option<usize>) -> Option<&'a entropyk_cli::run::StateEntry> {
fn find_edge(
result: &SimulationResult,
edge: Option<usize>,
) -> Option<&entropyk_cli::run::StateEntry> {
let edge = edge?;
result.state.as_ref()?.iter().find(|e| e.edge == edge)
}

View File

@@ -7,9 +7,7 @@
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use entropyk_components::{Component, ConnectedPort};
// =============================================================================
// Compressor - Real AHRI 540 Implementation
@@ -117,6 +115,149 @@ impl PyCompressor {
}
}
// =============================================================================
// ScrewEconomizerCompressor - 3-port screw compressor with economizer
// =============================================================================
/// A screw compressor with an intermediate economizer injection port.
#[pyclass(name = "ScrewEconomizerCompressor", module = "entropyk")]
#[derive(Clone)]
pub struct PyScrewEconomizerCompressor {
pub(crate) fluid: String,
pub(crate) nominal_frequency_hz: f64,
pub(crate) frequency_hz: f64,
pub(crate) mechanical_efficiency: f64,
pub(crate) economizer_fraction: f64,
}
#[pymethods]
impl PyScrewEconomizerCompressor {
/// Create a screw compressor with a fixed economizer flow fraction.
#[new]
#[pyo3(signature = (
fluid="R134a",
nominal_frequency_hz=50.0,
frequency_hz=50.0,
mechanical_efficiency=0.92,
economizer_fraction=0.12
))]
fn new(
fluid: &str,
nominal_frequency_hz: f64,
frequency_hz: f64,
mechanical_efficiency: f64,
economizer_fraction: f64,
) -> PyResult<Self> {
if nominal_frequency_hz <= 0.0 {
return Err(PyValueError::new_err(
"nominal_frequency_hz must be positive",
));
}
if frequency_hz < 0.0 {
return Err(PyValueError::new_err("frequency_hz cannot be negative"));
}
if !(0.0..=1.0).contains(&mechanical_efficiency) || mechanical_efficiency == 0.0 {
return Err(PyValueError::new_err(
"mechanical_efficiency must be in (0.0, 1.0]",
));
}
if !(0.0..=0.4).contains(&economizer_fraction) {
return Err(PyValueError::new_err(
"economizer_fraction must be between 0.0 and 0.4",
));
}
Ok(Self {
fluid: fluid.to_string(),
nominal_frequency_hz,
frequency_hz,
mechanical_efficiency,
economizer_fraction,
})
}
#[getter]
fn fluid(&self) -> String {
self.fluid.clone()
}
#[getter]
fn frequency_hz(&self) -> f64 {
self.frequency_hz
}
#[setter]
fn set_frequency_hz(&mut self, value: f64) -> PyResult<()> {
if value < 0.0 {
return Err(PyValueError::new_err("frequency_hz cannot be negative"));
}
self.frequency_hz = value;
Ok(())
}
#[getter]
fn mechanical_efficiency(&self) -> f64 {
self.mechanical_efficiency
}
#[getter]
fn economizer_fraction(&self) -> f64 {
self.economizer_fraction
}
fn __repr__(&self) -> String {
format!(
"ScrewEconomizerCompressor(fluid={}, frequency={:.1} Hz, eco_fraction={:.3})",
self.fluid, self.frequency_hz, self.economizer_fraction
)
}
}
impl PyScrewEconomizerCompressor {
pub(crate) fn build(&self) -> Box<dyn Component> {
use entropyk_components::port::{FluidId, Port};
use entropyk_components::{
Polynomial2D, ScrewEconomizerCompressor, ScrewPerformanceCurves,
};
use entropyk_core::{Enthalpy, Pressure};
fn connected_port(fluid: &str, pressure_bar: f64, enthalpy_kj_kg: f64) -> ConnectedPort {
let a = Port::new(
FluidId::new(fluid),
Pressure::from_bar(pressure_bar),
Enthalpy::from_joules_per_kg(enthalpy_kj_kg * 1000.0),
);
let b = Port::new(
FluidId::new(fluid),
Pressure::from_bar(pressure_bar),
Enthalpy::from_joules_per_kg(enthalpy_kj_kg * 1000.0),
);
a.connect(b)
.expect("matching screw compressor ports should connect")
.0
}
let curves = ScrewPerformanceCurves::with_fixed_eco_fraction(
Polynomial2D::bilinear(1.2, 0.003, -0.002, 0.000_01),
Polynomial2D::bilinear(55_000.0, 200.0, -300.0, 0.5),
self.economizer_fraction,
);
let mut component = ScrewEconomizerCompressor::new(
curves,
&self.fluid,
self.nominal_frequency_hz,
self.mechanical_efficiency,
connected_port(&self.fluid, 3.2, 400.0),
connected_port(&self.fluid, 12.8, 440.0),
connected_port(&self.fluid, 6.4, 260.0),
)
.expect("validated screw compressor parameters should build");
component
.set_frequency_hz(self.frequency_hz)
.expect("validated screw compressor frequency should set");
Box::new(component)
}
}
// =============================================================================
// Condenser - Real Heat Exchanger with Water Side
// =============================================================================
@@ -717,7 +858,11 @@ impl PyRefrigerantSource {
impl PyRefrigerantSource {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(entropyk_components::PyRefrigerantSourceReal::new(&self.fluid, self.p_set_pa, self.quality))
Box::new(entropyk_components::PyRefrigerantSourceReal::new(
&self.fluid,
self.p_set_pa,
self.quality,
))
}
}
@@ -760,7 +905,11 @@ impl PyRefrigerantSink {
impl PyRefrigerantSink {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(entropyk_components::PyRefrigerantSinkReal::new(&self.fluid, self.p_back_pa, self.quality_opt))
Box::new(entropyk_components::PyRefrigerantSinkReal::new(
&self.fluid,
self.p_back_pa,
self.quality_opt,
))
}
}
@@ -782,7 +931,12 @@ pub struct PyBrineSource {
impl PyBrineSource {
#[new]
#[pyo3(signature = (fluid="Water", concentration=0.0, temperature_k=300.0, pressure_pa=101325.0))]
fn new(fluid: &str, concentration: f64, temperature_k: f64, pressure_pa: f64) -> PyResult<Self> {
fn new(
fluid: &str,
concentration: f64,
temperature_k: f64,
pressure_pa: f64,
) -> PyResult<Self> {
if pressure_pa <= 0.0 {
return Err(PyValueError::new_err("pressure_pa must be positive"));
}
@@ -790,7 +944,9 @@ impl PyBrineSource {
return Err(PyValueError::new_err("temperature_k must be positive"));
}
if !(0.0..=1.0).contains(&concentration) {
return Err(PyValueError::new_err("concentration must be between 0.0 and 1.0"));
return Err(PyValueError::new_err(
"concentration must be between 0.0 and 1.0",
));
}
Ok(PyBrineSource {
fluid: fluid.to_string(),
@@ -810,7 +966,12 @@ impl PyBrineSource {
impl PyBrineSource {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(entropyk_components::PyBrineSourceReal::new(&self.fluid, self.concentration, self.temperature_k, self.pressure_pa))
Box::new(entropyk_components::PyBrineSourceReal::new(
&self.fluid,
self.concentration,
self.temperature_k,
self.pressure_pa,
))
}
}
@@ -868,7 +1029,9 @@ impl PyAirSource {
return Err(PyValueError::new_err("temperature_k must be positive"));
}
if !(0.0..=1.0).contains(&relative_humidity) {
return Err(PyValueError::new_err("relative_humidity must be between 0.0 and 1.0"));
return Err(PyValueError::new_err(
"relative_humidity must be between 0.0 and 1.0",
));
}
Ok(PyAirSource {
temperature_k,
@@ -887,7 +1050,11 @@ impl PyAirSource {
impl PyAirSource {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(entropyk_components::PyAirSourceReal::new(self.temperature_k, self.relative_humidity, self.pressure_pa))
Box::new(entropyk_components::PyAirSourceReal::new(
self.temperature_k,
self.relative_humidity,
self.pressure_pa,
))
}
}
@@ -920,6 +1087,122 @@ impl PyAirSink {
}
}
/// Capillary tube (1D segmented ΔP, isenthalpic).
#[pyclass(name = "CapillaryTube", module = "entropyk")]
#[derive(Clone)]
pub struct PyCapillaryTube {
fluid: String,
diameter_m: f64,
length_m: f64,
n_segments: usize,
}
#[pymethods]
impl PyCapillaryTube {
#[new]
#[pyo3(signature = (fluid="R134a", diameter_m=0.001, length_m=2.0, n_segments=20))]
fn new(fluid: &str, diameter_m: f64, length_m: f64, n_segments: usize) -> PyResult<Self> {
if diameter_m <= 0.0 || length_m <= 0.0 || n_segments < 2 {
return Err(PyValueError::new_err("invalid capillary geometry"));
}
Ok(Self {
fluid: fluid.to_string(),
diameter_m,
length_m,
n_segments,
})
}
fn __repr__(&self) -> String {
format!(
"CapillaryTube(D={:.3}mm, L={:.2}m)",
self.diameter_m * 1000.0,
self.length_m
)
}
}
impl PyCapillaryTube {
pub(crate) fn build(&self) -> Box<dyn Component> {
use entropyk_components::port::{FluidId, Port};
use entropyk_components::{CapillaryGeometry, CapillaryTube};
use entropyk_core::{Enthalpy, Pressure};
let geom = CapillaryGeometry {
diameter_m: self.diameter_m,
length_m: self.length_m,
n_segments: self.n_segments,
};
let p = Pressure::from_bar(10.0);
let h = Enthalpy::from_joules_per_kg(250_000.0);
let fid = FluidId::new(&self.fluid);
let tube = CapillaryTube::new(
geom,
Port::new(fid.clone(), p, h),
Port::new(fid.clone(), p, h),
)
.and_then(|c| c.connect(Port::new(fid.clone(), p, h), Port::new(fid, p, h)))
.expect("CapillaryTube construction");
Box::new(tube)
}
}
/// Fan-coil unit rating (ε-NTU + BPF).
#[pyclass(name = "FanCoilUnit", module = "entropyk")]
#[derive(Clone)]
pub struct PyFanCoilUnit {
ua: f64,
bpf: f64,
}
#[pymethods]
impl PyFanCoilUnit {
#[new]
#[pyo3(signature = (ua=8000.0, bypass_factor=0.15))]
fn new(ua: f64, bypass_factor: f64) -> Self {
Self {
ua,
bpf: bypass_factor,
}
}
/// Rate total / sensible / latent capacity [W] and leaving air temp [K].
#[pyo3(signature = (
t_air_in_k, t_dew_in_k, t_water_in_k, c_air, c_water, t_adp_k
))]
fn rate(
&self,
t_air_in_k: f64,
t_dew_in_k: f64,
t_water_in_k: f64,
c_air: f64,
c_water: f64,
t_adp_k: f64,
) -> (f64, f64, f64, f64, f64) {
use entropyk_components::heat_exchanger::{FanCoilRatingInput, FanCoilUnit};
let fcu = FanCoilUnit::new(self.ua).with_bypass_factor(self.bpf);
let r = fcu.rate(&FanCoilRatingInput {
t_air_in_k,
t_dew_in_k,
t_water_in_k,
c_air,
c_water,
bypass_factor: self.bpf,
t_adp_k,
});
(
r.q_total_w,
r.q_sensible_w,
r.q_latent_w,
r.t_air_out_k,
r.shr,
)
}
fn __repr__(&self) -> String {
format!("FanCoilUnit(UA={:.0}, BPF={:.2})", self.ua, self.bpf)
}
}
// =============================================================================
// Component enum for type-erasure
// =============================================================================
@@ -928,6 +1211,7 @@ impl PyAirSink {
#[derive(Clone)]
pub(crate) enum AnyPyComponent {
Compressor(PyCompressor),
ScrewEconomizerCompressor(PyScrewEconomizerCompressor),
Condenser(PyCondenser),
Evaporator(PyEvaporator),
Economizer(PyEconomizer),
@@ -945,6 +1229,7 @@ pub(crate) enum AnyPyComponent {
BrineSink(PyBrineSink),
AirSource(PyAirSource),
AirSink(PyAirSink),
CapillaryTube(PyCapillaryTube),
}
impl AnyPyComponent {
@@ -952,6 +1237,7 @@ impl AnyPyComponent {
pub(crate) fn build(&self) -> Box<dyn Component> {
match self {
AnyPyComponent::Compressor(c) => c.build(),
AnyPyComponent::ScrewEconomizerCompressor(c) => c.build(),
AnyPyComponent::Condenser(c) => c.build(),
AnyPyComponent::Evaporator(c) => c.build(),
AnyPyComponent::Economizer(c) => c.build(),
@@ -969,6 +1255,7 @@ impl AnyPyComponent {
AnyPyComponent::BrineSink(c) => c.build(),
AnyPyComponent::AirSource(c) => c.build(),
AnyPyComponent::AirSink(c) => c.build(),
AnyPyComponent::CapillaryTube(c) => c.build(),
}
}
}

View File

@@ -3,6 +3,11 @@
//! This crate provides Python wrappers for the Entropyk thermodynamic
//! simulation library via PyO3 + Maturin.
// Pre-existing lint debt accumulated before CI was introduced.
#![allow(deprecated)]
#![allow(dead_code)]
#![allow(clippy::too_many_arguments)]
use pyo3::prelude::*;
pub(crate) mod components;
@@ -28,6 +33,7 @@ fn entropyk(m: &Bound<'_, PyModule>) -> PyResult<()> {
// Components
m.add_class::<components::PyCompressor>()?;
m.add_class::<components::PyScrewEconomizerCompressor>()?;
m.add_class::<components::PyCondenser>()?;
m.add_class::<components::PyEvaporator>()?;
m.add_class::<components::PyEconomizer>()?;
@@ -45,6 +51,8 @@ fn entropyk(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<components::PyBrineSink>()?;
m.add_class::<components::PyAirSource>()?;
m.add_class::<components::PyAirSink>()?;
m.add_class::<components::PyCapillaryTube>()?;
m.add_class::<components::PyFanCoilUnit>()?;
m.add_class::<components::PyOperationalState>()?;
// Solver

View File

@@ -249,6 +249,9 @@ fn extract_component(obj: &Bound<'_, PyAny>) -> PyResult<AnyPyComponent> {
if let Ok(c) = obj.extract::<crate::components::PyCompressor>() {
return Ok(AnyPyComponent::Compressor(c));
}
if let Ok(c) = obj.extract::<crate::components::PyScrewEconomizerCompressor>() {
return Ok(AnyPyComponent::ScrewEconomizerCompressor(c));
}
if let Ok(c) = obj.extract::<crate::components::PyCondenser>() {
return Ok(AnyPyComponent::Condenser(c));
}
@@ -301,7 +304,7 @@ fn extract_component(obj: &Bound<'_, PyAny>) -> PyResult<AnyPyComponent> {
return Ok(AnyPyComponent::AirSink(c));
}
Err(PyValueError::new_err(
"Expected a component (Compressor, Condenser, Evaporator, ExpansionValve, Pipe, Pump, Fan, Economizer, FlowSplitter, FlowMerger, FlowSource, FlowSink, RefrigerantSource, RefrigerantSink, BrineSource, BrineSink, AirSource, AirSink)",
"Expected a component (Compressor, ScrewEconomizerCompressor, Condenser, Evaporator, ExpansionValve, Pipe, Pump, Fan, Economizer, FlowSplitter, FlowMerger, FlowSource, FlowSink, RefrigerantSource, RefrigerantSink, BrineSource, BrineSink, AirSource, AirSink)",
))
}
@@ -519,7 +522,9 @@ impl PyNewtonConfig {
return Err(PyValueError::new_err("tolerance must be greater than 0"));
}
if divergence_threshold <= tolerance {
return Err(PyValueError::new_err("divergence_threshold must be greater than tolerance"));
return Err(PyValueError::new_err(
"divergence_threshold must be greater than tolerance",
));
}
Ok(PyNewtonConfig {
max_iterations,
@@ -565,7 +570,10 @@ impl PyNewtonConfig {
previous_state: self.previous_state.clone(),
previous_residual: None,
initial_state: self.initial_state.clone(),
convergence_criteria: self.convergence_criteria.as_ref().map(|cc| cc.inner.clone()),
convergence_criteria: self
.convergence_criteria
.as_ref()
.map(|cc| cc.inner.clone()),
jacobian_freezing: self.jacobian_freezing.as_ref().map(|jf| jf.inner.clone()),
verbose_config: Default::default(),
};
@@ -631,8 +639,8 @@ impl PyPicardConfig {
convergence_criteria=None
))]
fn new(
max_iterations: usize,
tolerance: f64,
max_iterations: usize,
tolerance: f64,
relaxation: f64,
initial_state: Option<Vec<f64>>,
timeout_ms: Option<u64>,
@@ -675,7 +683,10 @@ impl PyPicardConfig {
relaxation_factor: self.relaxation,
timeout: self.timeout_ms.map(Duration::from_millis),
initial_state: self.initial_state.clone(),
convergence_criteria: self.convergence_criteria.as_ref().map(|cc| cc.inner.clone()),
convergence_criteria: self
.convergence_criteria
.as_ref()
.map(|cc| cc.inner.clone()),
..Default::default()
};
@@ -725,8 +736,14 @@ impl PyFallbackConfig {
#[pyo3(signature = (newton=None, picard=None))]
fn new(newton: Option<PyNewtonConfig>, picard: Option<PyPicardConfig>) -> PyResult<Self> {
Ok(PyFallbackConfig {
newton: newton.unwrap_or_else(|| PyNewtonConfig::new(100, 1e-6, false, None, None, false, None, None, None, None, 1e-4, 20, 1e10).unwrap()),
picard: picard.unwrap_or_else(|| PyPicardConfig::new(500, 1e-4, 0.5, None, None, None).unwrap()),
newton: newton.unwrap_or_else(|| {
PyNewtonConfig::new(
100, 1e-6, false, None, None, false, None, None, None, None, 1e-4, 20, 1e10,
)
.unwrap()
}),
picard: picard
.unwrap_or_else(|| PyPicardConfig::new(500, 1e-4, 0.5, None, None, None).unwrap()),
})
}
@@ -756,8 +773,16 @@ impl PyFallbackConfig {
previous_state: self.newton.previous_state.clone(),
previous_residual: None,
initial_state: self.newton.initial_state.clone(),
convergence_criteria: self.newton.convergence_criteria.as_ref().map(|cc| cc.inner.clone()),
jacobian_freezing: self.newton.jacobian_freezing.as_ref().map(|jf| jf.inner.clone()),
convergence_criteria: self
.newton
.convergence_criteria
.as_ref()
.map(|cc| cc.inner.clone()),
jacobian_freezing: self
.newton
.jacobian_freezing
.as_ref()
.map(|jf| jf.inner.clone()),
verbose_config: Default::default(),
};
let picard_config = entropyk_solver::PicardConfig {
@@ -766,7 +791,11 @@ impl PyFallbackConfig {
relaxation_factor: self.picard.relaxation,
timeout: self.picard.timeout_ms.map(Duration::from_millis),
initial_state: self.picard.initial_state.clone(),
convergence_criteria: self.picard.convergence_criteria.as_ref().map(|cc| cc.inner.clone()),
convergence_criteria: self
.picard
.convergence_criteria
.as_ref()
.map(|cc| cc.inner.clone()),
..Default::default()
};
let mut fallback =
@@ -982,10 +1011,19 @@ impl PySolverStrategy {
divergence_threshold: f64,
) -> PyResult<Self> {
let py_config = PyNewtonConfig::new(
max_iterations, tolerance, line_search, timeout_ms, initial_state,
use_numerical_jacobian, jacobian_freezing, convergence_criteria,
timeout_config, previous_state, line_search_armijo_c,
line_search_max_backtracks, divergence_threshold,
max_iterations,
tolerance,
line_search,
timeout_ms,
initial_state,
use_numerical_jacobian,
jacobian_freezing,
convergence_criteria,
timeout_config,
previous_state,
line_search_armijo_c,
line_search_max_backtracks,
divergence_threshold,
)?;
let config = entropyk_solver::NewtonConfig {
max_iterations: py_config.max_iterations,
@@ -1000,8 +1038,14 @@ impl PySolverStrategy {
previous_state: py_config.previous_state.clone(),
previous_residual: None,
initial_state: py_config.initial_state.clone(),
convergence_criteria: py_config.convergence_criteria.as_ref().map(|cc| cc.inner.clone()),
jacobian_freezing: py_config.jacobian_freezing.as_ref().map(|jf| jf.inner.clone()),
convergence_criteria: py_config
.convergence_criteria
.as_ref()
.map(|cc| cc.inner.clone()),
jacobian_freezing: py_config
.jacobian_freezing
.as_ref()
.map(|jf| jf.inner.clone()),
verbose_config: Default::default(),
};
Ok(PySolverStrategy {
@@ -1019,15 +1063,20 @@ impl PySolverStrategy {
convergence_criteria=None
))]
fn picard(
max_iterations: usize,
tolerance: f64,
max_iterations: usize,
tolerance: f64,
relaxation: f64,
initial_state: Option<Vec<f64>>,
timeout_ms: Option<u64>,
convergence_criteria: Option<PyConvergenceCriteria>,
) -> PyResult<Self> {
let py_config = PyPicardConfig::new(
max_iterations, tolerance, relaxation, initial_state, timeout_ms, convergence_criteria
max_iterations,
tolerance,
relaxation,
initial_state,
timeout_ms,
convergence_criteria,
)?;
let config = entropyk_solver::PicardConfig {
max_iterations: py_config.max_iterations,
@@ -1035,7 +1084,10 @@ impl PySolverStrategy {
relaxation_factor: py_config.relaxation,
timeout: py_config.timeout_ms.map(Duration::from_millis),
initial_state: py_config.initial_state.clone(),
convergence_criteria: py_config.convergence_criteria.as_ref().map(|cc| cc.inner.clone()),
convergence_criteria: py_config
.convergence_criteria
.as_ref()
.map(|cc| cc.inner.clone()),
..Default::default()
};
Ok(PySolverStrategy {
@@ -1052,7 +1104,7 @@ impl PySolverStrategy {
fn solve(&mut self, system: &mut PySystem) -> PyResult<PyConvergedState> {
use entropyk_solver::Solver;
let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
self.inner.solve(&mut system.inner)
}));

View File

@@ -345,7 +345,6 @@ impl PyMassFlow {
}
}
// =============================================================================
// Concentration
// =============================================================================
@@ -361,7 +360,9 @@ impl PyConcentration {
#[new]
fn new(value: f64) -> PyResult<Self> {
if !(0.0..=1.0).contains(&value) {
return Err(pyo3::exceptions::PyValueError::new_err("Value must be between 0.0 and 1.0"));
return Err(pyo3::exceptions::PyValueError::new_err(
"Value must be between 0.0 and 1.0",
));
}
Ok(PyConcentration {
inner: entropyk::Concentration::from_fraction(value),
@@ -427,9 +428,13 @@ impl PyVolumeFlow {
#[new]
fn new(value: f64) -> PyResult<Self> {
if value < 0.0 {
return Err(pyo3::exceptions::PyValueError::new_err("Value cannot be negative"));
return Err(pyo3::exceptions::PyValueError::new_err(
"Value cannot be negative",
));
}
Ok(PyVolumeFlow { inner: entropyk::VolumeFlow::from_m3_per_s(value) })
Ok(PyVolumeFlow {
inner: entropyk::VolumeFlow::from_m3_per_s(value),
})
}
#[getter]
@@ -439,12 +444,16 @@ impl PyVolumeFlow {
#[staticmethod]
fn from_m3_per_s(value: f64) -> Self {
PyVolumeFlow { inner: entropyk::VolumeFlow::from_m3_per_s(value) }
PyVolumeFlow {
inner: entropyk::VolumeFlow::from_m3_per_s(value),
}
}
#[staticmethod]
fn from_l_per_min(value: f64) -> Self {
PyVolumeFlow { inner: entropyk::VolumeFlow::from_l_per_min(value) }
PyVolumeFlow {
inner: entropyk::VolumeFlow::from_l_per_min(value),
}
}
fn to_m3_per_s(&self) -> f64 {
@@ -483,9 +492,13 @@ impl PyRelativeHumidity {
#[new]
fn new(value: f64) -> PyResult<Self> {
if !(0.0..=1.0).contains(&value) {
return Err(pyo3::exceptions::PyValueError::new_err("Value must be between 0.0 and 1.0"));
return Err(pyo3::exceptions::PyValueError::new_err(
"Value must be between 0.0 and 1.0",
));
}
Ok(PyRelativeHumidity { inner: entropyk::RelativeHumidity::from_fraction(value) })
Ok(PyRelativeHumidity {
inner: entropyk::RelativeHumidity::from_fraction(value),
})
}
#[getter]
@@ -495,12 +508,16 @@ impl PyRelativeHumidity {
#[staticmethod]
fn from_percent(value: f64) -> Self {
PyRelativeHumidity { inner: entropyk::RelativeHumidity::from_percent(value) }
PyRelativeHumidity {
inner: entropyk::RelativeHumidity::from_percent(value),
}
}
#[staticmethod]
fn from_fraction(value: f64) -> Self {
PyRelativeHumidity { inner: entropyk::RelativeHumidity::from_fraction(value) }
PyRelativeHumidity {
inner: entropyk::RelativeHumidity::from_fraction(value),
}
}
fn to_percent(&self) -> f64 {
@@ -539,9 +556,13 @@ impl PyVaporQuality {
#[new]
fn new(value: f64) -> PyResult<Self> {
if !(0.0..=1.0).contains(&value) {
return Err(pyo3::exceptions::PyValueError::new_err("Value must be between 0.0 and 1.0"));
return Err(pyo3::exceptions::PyValueError::new_err(
"Value must be between 0.0 and 1.0",
));
}
Ok(PyVaporQuality { inner: entropyk::VaporQuality::from_fraction(value) })
Ok(PyVaporQuality {
inner: entropyk::VaporQuality::from_fraction(value),
})
}
#[getter]
@@ -551,12 +572,16 @@ impl PyVaporQuality {
#[staticmethod]
fn from_fraction(value: f64) -> Self {
PyVaporQuality { inner: entropyk::VaporQuality::from_fraction(value) }
PyVaporQuality {
inner: entropyk::VaporQuality::from_fraction(value),
}
}
#[staticmethod]
fn from_percent(value: f64) -> Self {
PyVaporQuality { inner: entropyk::VaporQuality::from_percent(value) }
PyVaporQuality {
inner: entropyk::VaporQuality::from_percent(value),
}
}
fn to_fraction(&self) -> f64 {
@@ -579,4 +604,3 @@ impl PyVaporQuality {
(self.inner.0 - other.inner.0).abs() < 1e-10
}
}

View File

@@ -67,7 +67,11 @@ pub fn load_fluid_table(json: String) -> Result<(), String> {
/// Get list of available fluids in the default backend.
#[wasm_bindgen]
pub fn list_available_fluids() -> Vec<String> {
global_backend().list_fluids().into_iter().map(|f| f.0).collect()
global_backend()
.list_fluids()
.into_iter()
.map(|f| f.0)
.collect()
}
#[cfg(test)]

View File

@@ -100,6 +100,96 @@ impl WasmCompressor {
}
}
/// WASM wrapper for a screw compressor with economizer injection.
#[wasm_bindgen]
pub struct WasmScrewEconomizerCompressor {
pub(crate) inner: entropyk_components::ScrewEconomizerCompressor,
}
#[wasm_bindgen]
impl WasmScrewEconomizerCompressor {
/// Create a new screw economizer compressor with default bilinear curves.
#[wasm_bindgen(constructor)]
pub fn new(
fluid: String,
nominal_frequency_hz: f64,
frequency_hz: f64,
mechanical_efficiency: f64,
economizer_fraction: f64,
) -> Result<WasmScrewEconomizerCompressor, JsValue> {
if nominal_frequency_hz <= 0.0 {
return Err(js_sys::Error::new("nominal_frequency_hz must be positive").into());
}
if frequency_hz < 0.0 {
return Err(js_sys::Error::new("frequency_hz cannot be negative").into());
}
if !(0.0..=1.0).contains(&mechanical_efficiency) || mechanical_efficiency == 0.0 {
return Err(js_sys::Error::new("mechanical_efficiency must be in (0.0, 1.0]").into());
}
if !(0.0..=0.4).contains(&economizer_fraction) {
return Err(
js_sys::Error::new("economizer_fraction must be between 0.0 and 0.4").into(),
);
}
fn connected_port(
fluid: &str,
pressure_bar: f64,
enthalpy_kj_kg: f64,
) -> Result<entropyk_components::ConnectedPort, JsValue> {
let fluid_id = FluidId::new(fluid);
let port_a = Port::new(
fluid_id.clone(),
entropyk_core::Pressure::from_bar(pressure_bar),
entropyk_core::Enthalpy::from_joules_per_kg(enthalpy_kj_kg * 1000.0),
);
let port_b = Port::new(
fluid_id,
entropyk_core::Pressure::from_bar(pressure_bar),
entropyk_core::Enthalpy::from_joules_per_kg(enthalpy_kj_kg * 1000.0),
);
port_a
.connect(port_b)
.map(|(connected, _)| connected)
.map_err(|e| js_sys::Error::new(&format!("Port connection failed: {}", e)).into())
}
let curves = entropyk_components::ScrewPerformanceCurves::with_fixed_eco_fraction(
entropyk_components::Polynomial2D::bilinear(1.2, 0.003, -0.002, 0.000_01),
entropyk_components::Polynomial2D::bilinear(55_000.0, 200.0, -300.0, 0.5),
economizer_fraction,
);
let mut inner = entropyk_components::ScrewEconomizerCompressor::new(
curves,
&fluid,
nominal_frequency_hz,
mechanical_efficiency,
connected_port(&fluid, 3.2, 400.0)?,
connected_port(&fluid, 12.8, 440.0)?,
connected_port(&fluid, 6.4, 260.0)?,
)
.map_err(|e| js_sys::Error::new(&format!("Screw compressor creation failed: {}", e)))?;
inner
.set_frequency_hz(frequency_hz)
.map_err(|e| js_sys::Error::new(&format!("Frequency update failed: {}", e)))?;
Ok(WasmScrewEconomizerCompressor { inner })
}
/// Current VFD frequency in Hz.
pub fn frequency_hz(&self) -> f64 {
self.inner.frequency_hz()
}
/// Convert to a generic WasmComponent.
pub fn into_component(self) -> WasmComponent {
WasmComponent {
inner: Box::new(self.inner),
name: "ScrewEconomizerCompressor".to_string(),
}
}
}
/// WASM wrapper for Condenser.
#[wasm_bindgen]
pub struct WasmCondenser {
@@ -269,3 +359,48 @@ impl WasmPipe {
}
}
}
/// WASM wrapper for CapillaryTube.
#[wasm_bindgen]
pub struct WasmCapillaryTube {
pub(crate) inner: entropyk_components::CapillaryTube<entropyk_components::port::Connected>,
}
#[wasm_bindgen]
impl WasmCapillaryTube {
/// Create a capillary tube (diameter_m, length_m).
#[wasm_bindgen(constructor)]
pub fn new(
fluid: String,
diameter_m: f64,
length_m: f64,
) -> Result<WasmCapillaryTube, JsValue> {
use entropyk_components::{CapillaryGeometry, CapillaryTube};
let fluid_id = FluidId::new(&fluid);
let p = entropyk_core::Pressure::from_bar(10.0);
let h = entropyk_core::Enthalpy::from_joules_per_kg(250000.0);
let geom = CapillaryGeometry {
diameter_m,
length_m,
n_segments: 20,
};
let tube = CapillaryTube::new(
geom,
Port::new(fluid_id.clone(), p, h),
Port::new(fluid_id.clone(), p, h),
)
.map_err(|e| js_sys::Error::new(&format!("{e}")))?;
let connected = tube
.connect(Port::new(fluid_id.clone(), p, h), Port::new(fluid_id, p, h))
.map_err(|e| js_sys::Error::new(&format!("{e}")))?;
Ok(WasmCapillaryTube { inner: connected })
}
/// Convert to a generic WasmComponent.
pub fn into_component(self) -> WasmComponent {
WasmComponent {
inner: Box::new(self.inner),
name: "CapillaryTube".to_string(),
}
}
}

View File

@@ -3,6 +3,10 @@
//! This crate provides WebAssembly wrappers for the Entropyk thermodynamic
//! simulation library via wasm-bindgen.
// Pre-existing lint debt: wasm-bindgen exposes camelCase JS names.
#![allow(non_snake_case)]
#![allow(clippy::too_many_arguments)]
use wasm_bindgen::prelude::*;
pub mod backend;

View File

@@ -7,7 +7,7 @@ use crate::components::WasmComponent;
use crate::types::{WasmConvergedState, WasmThermoState};
use entropyk_fluids::FluidBackend;
use entropyk_solver::{
ConvergenceStatus, ConvergedState, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig,
ConvergedState, ConvergenceStatus, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig,
Solver, SolverStrategy, System,
};
use petgraph::graph::NodeIndex;
@@ -202,7 +202,7 @@ impl WasmSystem {
pub fn solve(&mut self, config: WasmFallbackConfig) -> Result<WasmConvergedState, JsValue> {
let mut solver = FallbackSolver::new(config.inner);
let state = solver
.solve(&mut *self.inner.borrow_mut())
.solve(&mut self.inner.borrow_mut())
.map_err(|e| js_sys::Error::new(&e.to_string()))?;
*self.last_state.borrow_mut() = Some(state.state.clone());
@@ -216,7 +216,7 @@ impl WasmSystem {
) -> Result<WasmConvergedState, JsValue> {
let mut solver = SolverStrategy::NewtonRaphson(config.inner);
let state = solver
.solve(&mut *self.inner.borrow_mut())
.solve(&mut self.inner.borrow_mut())
.map_err(|e| js_sys::Error::new(&e.to_string()))?;
*self.last_state.borrow_mut() = Some(state.state.clone());
@@ -251,7 +251,9 @@ impl WasmSystem {
if *p_idx >= state.len() || *h_idx >= state.len() {
return Err(js_sys::Error::new(&format!(
"State index out of bounds: p_idx={}, h_idx={}, state_len={}",
p_idx, h_idx, state.len()
p_idx,
h_idx,
state.len()
))
.into());
}