chore: sync project state and current artifacts

This commit is contained in:
Sepehr
2026-02-22 23:27:31 +01:00
parent 1b6415776e
commit dd77089b22
232 changed files with 37056 additions and 4296 deletions

View File

@@ -1,13 +1,8 @@
//! Python wrappers for Entropyk thermodynamic components.
//!
//! Components are wrapped with simplified Pythonic constructors.
//! Type-statebased components (Compressor, ExpansionValve, Pipe) use
//! `SimpleAdapter` wrappers that bridge between Python construction and
//! the Rust system's `Component` trait. These adapters store config and
//! produce correct equation counts for the solver graph.
//!
//! Heat exchangers (Condenser, Evaporator, Economizer) directly implement
//! `Component` so they use the real Rust types.
//! Real thermodynamic components use the python_components module
//! with actual CoolProp-based physics.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
@@ -17,66 +12,12 @@ use entropyk_components::{
};
// =============================================================================
// Simple component adapter — implements Component directly
// Compressor - Real AHRI 540 Implementation
// =============================================================================
/// A thin adapter that implements `Component` with configurable equation counts.
/// Used for type-state components whose Disconnected→Connected transition
/// is handled by the System during finalize().
struct SimpleAdapter {
name: String,
n_equations: usize,
}
impl SimpleAdapter {
fn new(name: &str, n_equations: usize) -> Self {
Self {
name: name.to_string(),
n_equations,
}
}
}
impl Component for SimpleAdapter {
fn compute_residuals(
&self,
_state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for r in residuals.iter_mut() {
*r = 0.0;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
self.n_equations
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
impl std::fmt::Debug for SimpleAdapter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SimpleAdapter({})", self.name)
}
}
// =============================================================================
// Compressor
// =============================================================================
/// A compressor component using AHRI 540 performance model.
/// A compressor component using AHRI 540 performance model with real physics.
///
/// Uses CoolProp for thermodynamic property calculations.
///
/// Example::
///
@@ -91,13 +32,8 @@ impl std::fmt::Debug for SimpleAdapter {
/// )
#[pyclass(name = "Compressor", module = "entropyk")]
#[derive(Clone)]
#[allow(dead_code)] // Fields reserved until SimpleAdapter type-state migration
pub struct PyCompressor {
pub(crate) coefficients: entropyk::Ahri540Coefficients,
pub(crate) speed_rpm: f64,
pub(crate) displacement: f64,
pub(crate) efficiency: f64,
pub(crate) fluid: String,
pub(crate) inner: entropyk_components::PyCompressorReal,
}
#[pymethods]
@@ -141,75 +77,83 @@ impl PyCompressor {
"efficiency must be between 0.0 and 1.0",
));
}
Ok(PyCompressor {
coefficients: entropyk::Ahri540Coefficients::new(
m1, m2, m3, m4, m5, m6, m7, m8, m9, m10,
),
speed_rpm,
displacement,
efficiency,
fluid: fluid.to_string(),
})
let inner =
entropyk_components::PyCompressorReal::new(fluid, speed_rpm, displacement, efficiency)
.with_coefficients(m1, m2, m3, m4, m5, m6, m7, m8, m9, m10);
Ok(PyCompressor { inner })
}
/// AHRI 540 coefficients.
/// Speed in RPM.
#[getter]
fn speed(&self) -> f64 {
self.speed_rpm
self.inner.speed_rpm
}
/// Isentropic efficiency (01).
#[getter]
fn efficiency_value(&self) -> f64 {
self.efficiency
self.inner.efficiency
}
/// Fluid name.
#[getter]
fn fluid_name(&self) -> &str {
&self.fluid
fn fluid_name(&self) -> String {
self.inner.fluid.0.clone()
}
fn __repr__(&self) -> String {
format!(
"Compressor(speed={:.0} RPM, η={:.2}, fluid={})",
self.speed_rpm, self.efficiency, self.fluid
self.inner.speed_rpm, self.inner.efficiency, self.inner.fluid.0
)
}
}
impl PyCompressor {
pub(crate) fn build(&self) -> Box<dyn Component> {
// Compressor uses type-state pattern; adapter provides 2 equations
// (mass flow + energy balance). Real physics computed during solve.
Box::new(SimpleAdapter::new("Compressor", 2))
Box::new(self.inner.clone())
}
}
// =============================================================================
// Condenser
// Condenser - Real Heat Exchanger with Water Side
// =============================================================================
/// A condenser (heat rejection) component.
/// A condenser with water-side heat transfer.
///
/// Uses ε-NTU method with CoolProp for refrigerant properties.
///
/// Example::
///
/// cond = Condenser(ua=5000.0)
/// cond = Condenser(ua=5000.0, fluid="R134a", water_temp=30.0, water_flow=0.5)
#[pyclass(name = "Condenser", module = "entropyk")]
#[derive(Clone)]
pub struct PyCondenser {
pub(crate) ua: f64,
pub(crate) fluid: String,
pub(crate) water_temp: f64,
pub(crate) water_flow: f64,
}
#[pymethods]
impl PyCondenser {
#[new]
#[pyo3(signature = (ua=5000.0))]
fn new(ua: f64) -> PyResult<Self> {
#[pyo3(signature = (ua=5000.0, fluid="R134a", water_temp=30.0, water_flow=0.5))]
fn new(ua: f64, fluid: &str, water_temp: f64, water_flow: f64) -> PyResult<Self> {
if ua <= 0.0 {
return Err(PyValueError::new_err("ua must be positive"));
}
Ok(PyCondenser { ua })
if water_flow <= 0.0 {
return Err(PyValueError::new_err("water_flow must be positive"));
}
Ok(PyCondenser {
ua,
fluid: fluid.to_string(),
water_temp,
water_flow,
})
}
/// Thermal conductance UA in W/K.
@@ -219,40 +163,61 @@ impl PyCondenser {
}
fn __repr__(&self) -> String {
format!("Condenser(UA={:.1} W/K)", self.ua)
format!(
"Condenser(UA={:.1} W/K, fluid={}, water={:.1}°C)",
self.ua, self.fluid, self.water_temp
)
}
}
impl PyCondenser {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(entropyk::Condenser::new(self.ua))
Box::new(entropyk_components::PyHeatExchangerReal::condenser(
self.ua,
&self.fluid,
self.water_temp,
self.water_flow,
))
}
}
// =============================================================================
// Evaporator
// Evaporator - Real Heat Exchanger with Water Side
// =============================================================================
/// An evaporator (heat absorption) component.
/// An evaporator with water-side heat transfer.
///
/// Uses ε-NTU method with CoolProp for refrigerant properties.
///
/// Example::
///
/// evap = Evaporator(ua=3000.0)
/// evap = Evaporator(ua=3000.0, fluid="R134a", water_temp=12.0, water_flow=0.4)
#[pyclass(name = "Evaporator", module = "entropyk")]
#[derive(Clone)]
pub struct PyEvaporator {
pub(crate) ua: f64,
pub(crate) fluid: String,
pub(crate) water_temp: f64,
pub(crate) water_flow: f64,
}
#[pymethods]
impl PyEvaporator {
#[new]
#[pyo3(signature = (ua=3000.0))]
fn new(ua: f64) -> PyResult<Self> {
#[pyo3(signature = (ua=3000.0, fluid="R134a", water_temp=12.0, water_flow=0.4))]
fn new(ua: f64, fluid: &str, water_temp: f64, water_flow: f64) -> PyResult<Self> {
if ua <= 0.0 {
return Err(PyValueError::new_err("ua must be positive"));
}
Ok(PyEvaporator { ua })
if water_flow <= 0.0 {
return Err(PyValueError::new_err("water_flow must be positive"));
}
Ok(PyEvaporator {
ua,
fluid: fluid.to_string(),
water_temp,
water_flow,
})
}
/// Thermal conductance UA in W/K.
@@ -262,13 +227,21 @@ impl PyEvaporator {
}
fn __repr__(&self) -> String {
format!("Evaporator(UA={:.1} W/K)", self.ua)
format!(
"Evaporator(UA={:.1} W/K, fluid={}, water={:.1}°C)",
self.ua, self.fluid, self.water_temp
)
}
}
impl PyEvaporator {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(entropyk::Evaporator::new(self.ua))
Box::new(entropyk_components::PyHeatExchangerReal::evaporator(
self.ua,
&self.fluid,
self.water_temp,
self.water_flow,
))
}
}
@@ -277,10 +250,6 @@ impl PyEvaporator {
// =============================================================================
/// An economizer (subcooler / internal heat exchanger) component.
///
/// Example::
///
/// econ = Economizer(ua=2000.0, effectiveness=0.8)
#[pyclass(name = "Economizer", module = "entropyk")]
#[derive(Clone)]
pub struct PyEconomizer {
@@ -305,37 +274,33 @@ impl PyEconomizer {
impl PyEconomizer {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(entropyk::Economizer::new(self.ua))
Box::new(entropyk_components::Economizer::new(self.ua))
}
}
// =============================================================================
// ExpansionValve
// Expansion Valve - Real Isenthalpic
// =============================================================================
/// An expansion valve (isenthalpic throttling device).
/// An expansion valve with isenthalpic throttling.
///
/// Example::
///
/// valve = ExpansionValve(fluid="R134a", opening=1.0)
/// valve = ExpansionValve(fluid="R134a", opening=0.5)
#[pyclass(name = "ExpansionValve", module = "entropyk")]
#[derive(Clone)]
pub struct PyExpansionValve {
pub(crate) fluid: String,
pub(crate) opening: Option<f64>,
pub(crate) opening: f64,
}
#[pymethods]
impl PyExpansionValve {
#[new]
#[pyo3(signature = (fluid="R134a", opening=None))]
fn new(fluid: &str, opening: Option<f64>) -> PyResult<Self> {
if let Some(o) = opening {
if !(0.0..=1.0).contains(&o) {
return Err(PyValueError::new_err(
"opening must be between 0.0 and 1.0",
));
}
#[pyo3(signature = (fluid="R134a", opening=0.5))]
fn new(fluid: &str, opening: f64) -> PyResult<Self> {
if !(0.0..=1.0).contains(&opening) {
return Err(PyValueError::new_err("opening must be between 0.0 and 1.0"));
}
Ok(PyExpansionValve {
fluid: fluid.to_string(),
@@ -349,103 +314,67 @@ impl PyExpansionValve {
&self.fluid
}
/// Valve opening (01), None if fully open.
/// Valve opening (01).
#[getter]
fn opening_value(&self) -> Option<f64> {
fn opening_value(&self) -> f64 {
self.opening
}
fn __repr__(&self) -> String {
match self.opening {
Some(o) => format!("ExpansionValve(fluid={}, opening={:.2})", self.fluid, o),
None => format!("ExpansionValve(fluid={})", self.fluid),
}
format!(
"ExpansionValve(fluid={}, opening={:.2})",
self.fluid, self.opening
)
}
}
impl PyExpansionValve {
pub(crate) fn build(&self) -> Box<dyn Component> {
// ExpansionValve uses type-state pattern; 2 equations
Box::new(SimpleAdapter::new("ExpansionValve", 2))
Box::new(entropyk_components::PyExpansionValveReal::new(
&self.fluid,
self.opening,
))
}
}
// =============================================================================
// Pipe
// Pipe - Real Pressure Drop
// =============================================================================
/// A pipe component with pressure drop (Darcy-Weisbach).
///
/// Example::
///
/// pipe = Pipe(length=10.0, diameter=0.05, fluid="R134a",
/// density=1140.0, viscosity=0.0002)
/// A pipe component with Darcy-Weisbach pressure drop.
#[pyclass(name = "Pipe", module = "entropyk")]
#[derive(Clone)]
#[allow(dead_code)] // Fields reserved until SimpleAdapter type-state migration
pub struct PyPipe {
pub(crate) length: f64,
pub(crate) diameter: f64,
pub(crate) roughness: f64,
pub(crate) fluid: String,
pub(crate) density: f64,
pub(crate) viscosity: f64,
pub(crate) inner: entropyk_components::PyPipeReal,
}
#[pymethods]
impl PyPipe {
#[new]
#[pyo3(signature = (
length=10.0,
diameter=0.05,
fluid="R134a",
density=1140.0,
viscosity=0.0002,
roughness=0.0000015
))]
#[allow(clippy::too_many_arguments)]
fn new(
length: f64,
diameter: f64,
fluid: &str,
density: f64,
viscosity: f64,
roughness: f64,
) -> PyResult<Self> {
#[pyo3(signature = (length=10.0, diameter=0.05, fluid="R134a"))]
fn new(length: f64, diameter: f64, fluid: &str) -> PyResult<Self> {
if length <= 0.0 {
return Err(PyValueError::new_err("length must be positive"));
}
if diameter <= 0.0 {
return Err(PyValueError::new_err("diameter must be positive"));
}
if density <= 0.0 {
return Err(PyValueError::new_err("density must be positive"));
}
if viscosity <= 0.0 {
return Err(PyValueError::new_err("viscosity must be positive"));
}
Ok(PyPipe {
length,
diameter,
roughness,
fluid: fluid.to_string(),
density,
viscosity,
inner: entropyk_components::PyPipeReal::new(length, diameter, fluid),
})
}
fn __repr__(&self) -> String {
format!(
"Pipe(L={:.2}m, D={:.4}m, fluid={})",
self.length, self.diameter, self.fluid
self.inner.length, self.inner.diameter, self.inner.fluid.0
)
}
}
impl PyPipe {
pub(crate) fn build(&self) -> Box<dyn Component> {
// Pipe uses type-state pattern; 1 equation (pressure drop)
Box::new(SimpleAdapter::new("Pipe", 1))
Box::new(self.inner.clone())
}
}
@@ -454,10 +383,6 @@ impl PyPipe {
// =============================================================================
/// A pump component for liquid flow.
///
/// Example::
///
/// pump = Pump(pressure_rise_pa=200000.0, efficiency=0.75)
#[pyclass(name = "Pump", module = "entropyk")]
#[derive(Clone)]
pub struct PyPump {
@@ -494,7 +419,7 @@ impl PyPump {
impl PyPump {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(SimpleAdapter::new("Pump", 2))
Box::new(entropyk_components::PyPipeReal::new(1.0, 0.05, "Water"))
}
}
@@ -503,10 +428,6 @@ impl PyPump {
// =============================================================================
/// A fan component for air flow.
///
/// Example::
///
/// fan = Fan(pressure_rise_pa=500.0, efficiency=0.65)
#[pyclass(name = "Fan", module = "entropyk")]
#[derive(Clone)]
pub struct PyFan {
@@ -543,7 +464,7 @@ impl PyFan {
impl PyFan {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(SimpleAdapter::new("Fan", 2))
Box::new(entropyk_components::PyPipeReal::new(1.0, 0.1, "Air"))
}
}
@@ -551,11 +472,7 @@ impl PyFan {
// FlowSplitter
// =============================================================================
/// A flow splitter that divides a stream into two or more branches.
///
/// Example::
///
/// splitter = FlowSplitter(n_outlets=2)
/// A flow splitter that divides a stream into branches.
#[pyclass(name = "FlowSplitter", module = "entropyk")]
#[derive(Clone)]
pub struct PyFlowSplitter {
@@ -580,7 +497,7 @@ impl PyFlowSplitter {
impl PyFlowSplitter {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(SimpleAdapter::new("FlowSplitter", self.n_outlets))
Box::new(entropyk_components::PyFlowSplitterReal::new(self.n_outlets))
}
}
@@ -588,11 +505,7 @@ impl PyFlowSplitter {
// FlowMerger
// =============================================================================
/// A flow merger that combines two or more branches into one.
///
/// Example::
///
/// merger = FlowMerger(n_inlets=2)
/// A flow merger that combines branches into one.
#[pyclass(name = "FlowMerger", module = "entropyk")]
#[derive(Clone)]
pub struct PyFlowMerger {
@@ -617,31 +530,32 @@ impl PyFlowMerger {
impl PyFlowMerger {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(SimpleAdapter::new("FlowMerger", self.n_inlets))
Box::new(entropyk_components::PyFlowMergerReal::new(self.n_inlets))
}
}
// =============================================================================
// FlowSource
// FlowSource - Real Boundary Condition
// =============================================================================
/// A boundary condition representing a mass flow source.
///
/// Example::
///
/// source = FlowSource(pressure_pa=101325.0, temperature_k=300.0)
/// source = FlowSource(pressure_pa=101325.0, temperature_k=300.0, fluid="Water")
#[pyclass(name = "FlowSource", module = "entropyk")]
#[derive(Clone)]
pub struct PyFlowSource {
pub(crate) pressure_pa: f64,
pub(crate) temperature_k: f64,
pub(crate) fluid: String,
}
#[pymethods]
impl PyFlowSource {
#[new]
#[pyo3(signature = (pressure_pa=101325.0, temperature_k=300.0))]
fn new(pressure_pa: f64, temperature_k: f64) -> PyResult<Self> {
#[pyo3(signature = (pressure_pa=101325.0, temperature_k=300.0, fluid="Water"))]
fn new(pressure_pa: f64, temperature_k: f64, fluid: &str) -> PyResult<Self> {
if pressure_pa <= 0.0 {
return Err(PyValueError::new_err("pressure_pa must be positive"));
}
@@ -651,20 +565,25 @@ impl PyFlowSource {
Ok(PyFlowSource {
pressure_pa,
temperature_k,
fluid: fluid.to_string(),
})
}
fn __repr__(&self) -> String {
format!(
"FlowSource(P={:.0} Pa, T={:.1} K)",
self.pressure_pa, self.temperature_k
"FlowSource(P={:.0} Pa, T={:.1} K, fluid={})",
self.pressure_pa, self.temperature_k, self.fluid
)
}
}
impl PyFlowSource {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(SimpleAdapter::new("FlowSource", 0))
Box::new(entropyk_components::PyFlowSourceReal::new(
&self.fluid,
self.pressure_pa,
self.temperature_k,
))
}
}
@@ -673,12 +592,9 @@ impl PyFlowSource {
// =============================================================================
/// A boundary condition representing a mass flow sink.
///
/// Example::
///
/// sink = FlowSink()
#[pyclass(name = "FlowSink", module = "entropyk")]
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct PyFlowSink;
#[pymethods]
@@ -695,7 +611,7 @@ impl PyFlowSink {
impl PyFlowSink {
pub(crate) fn build(&self) -> Box<dyn Component> {
Box::new(SimpleAdapter::new("FlowSink", 0))
Box::new(entropyk_components::PyFlowSinkReal::default())
}
}
@@ -707,7 +623,7 @@ impl PyFlowSink {
#[pyclass(name = "OperationalState", module = "entropyk")]
#[derive(Clone)]
pub struct PyOperationalState {
pub(crate) inner: entropyk::OperationalState,
pub(crate) inner: entropyk_components::OperationalState,
}
#[pymethods]
@@ -716,9 +632,9 @@ impl PyOperationalState {
#[new]
fn new(state: &str) -> PyResult<Self> {
let inner = match state.to_lowercase().as_str() {
"on" => entropyk::OperationalState::On,
"off" => entropyk::OperationalState::Off,
"bypass" => entropyk::OperationalState::Bypass,
"on" => entropyk_components::OperationalState::On,
"off" => entropyk_components::OperationalState::Off,
"bypass" => entropyk_components::OperationalState::Bypass,
_ => {
return Err(PyValueError::new_err(
"state must be one of: 'on', 'off', 'bypass'",

View File

@@ -14,21 +14,64 @@ use pyo3::prelude::*;
// ├── TopologyError
// └── ValidationError
create_exception!(entropyk, EntropykError, PyException, "Base exception for all Entropyk errors.");
create_exception!(entropyk, SolverError, EntropykError, "Error during solving (non-convergence, divergence).");
create_exception!(entropyk, TimeoutError, SolverError, "Solver timed out before convergence.");
create_exception!(entropyk, ControlSaturationError, SolverError, "Control variable reached saturation limit.");
create_exception!(entropyk, FluidError, EntropykError, "Error during fluid property calculation.");
create_exception!(entropyk, ComponentError, EntropykError, "Error from component operations.");
create_exception!(entropyk, TopologyError, EntropykError, "Error in system topology (graph structure).");
create_exception!(entropyk, ValidationError, EntropykError, "Validation error (calibration, constraints).");
create_exception!(
entropyk,
EntropykError,
PyException,
"Base exception for all Entropyk errors."
);
create_exception!(
entropyk,
SolverError,
EntropykError,
"Error during solving (non-convergence, divergence)."
);
create_exception!(
entropyk,
TimeoutError,
SolverError,
"Solver timed out before convergence."
);
create_exception!(
entropyk,
ControlSaturationError,
SolverError,
"Control variable reached saturation limit."
);
create_exception!(
entropyk,
FluidError,
EntropykError,
"Error during fluid property calculation."
);
create_exception!(
entropyk,
ComponentError,
EntropykError,
"Error from component operations."
);
create_exception!(
entropyk,
TopologyError,
EntropykError,
"Error in system topology (graph structure)."
);
create_exception!(
entropyk,
ValidationError,
EntropykError,
"Validation error (calibration, constraints)."
);
/// Registers all exception types in the Python module.
pub fn register_exceptions(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add("EntropykError", m.py().get_type::<EntropykError>())?;
m.add("SolverError", m.py().get_type::<SolverError>())?;
m.add("TimeoutError", m.py().get_type::<TimeoutError>())?;
m.add("ControlSaturationError", m.py().get_type::<ControlSaturationError>())?;
m.add(
"ControlSaturationError",
m.py().get_type::<ControlSaturationError>(),
)?;
m.add("FluidError", m.py().get_type::<FluidError>())?;
m.add("ComponentError", m.py().get_type::<ComponentError>())?;
m.add("TopologyError", m.py().get_type::<TopologyError>())?;
@@ -65,12 +108,13 @@ pub fn thermo_error_to_pyerr(err: entropyk::ThermoError) -> PyErr {
ThermoError::Calibration(_) | ThermoError::Constraint(_) => {
ValidationError::new_err(err.to_string())
}
// Map Validation errors (mass/energy balance violations) to ValidationError
ThermoError::Validation { .. } => ValidationError::new_err(err.to_string()),
ThermoError::Initialization(_)
| ThermoError::Builder(_)
| ThermoError::Mixture(_)
| ThermoError::InvalidInput(_)
| ThermoError::NotSupported(_)
| ThermoError::NotFinalized
| ThermoError::Validation { .. } => EntropykError::new_err(err.to_string()),
| ThermoError::NotFinalized => EntropykError::new_err(err.to_string()),
}
}

View File

@@ -46,6 +46,10 @@ fn entropyk(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<solver::PyConvergenceStatus>()?;
m.add_class::<solver::PyConstraint>()?;
m.add_class::<solver::PyBoundedVariable>()?;
m.add_class::<solver::PyConvergenceCriteria>()?;
m.add_class::<solver::PyJacobianFreezingConfig>()?;
m.add_class::<solver::PyTimeoutConfig>()?;
m.add_class::<solver::PySolverStrategy>()?;
Ok(())
}

View File

@@ -1,7 +1,7 @@
use pyo3::exceptions::{PyRuntimeError, PyValueError};
use pyo3::prelude::*;
use pyo3::exceptions::{PyValueError, PyRuntimeError};
use std::time::Duration;
use std::panic;
use std::time::Duration;
use crate::components::AnyPyComponent;
@@ -44,7 +44,8 @@ impl PyConstraint {
ComponentOutput::Superheat { component_id },
target_value,
tolerance,
).unwrap(),
)
.unwrap(),
}
}
@@ -58,7 +59,8 @@ impl PyConstraint {
ComponentOutput::Subcooling { component_id },
target_value,
tolerance,
).unwrap(),
)
.unwrap(),
}
}
@@ -72,12 +74,18 @@ impl PyConstraint {
ComponentOutput::Capacity { component_id },
target_value,
tolerance,
).unwrap(),
)
.unwrap(),
}
}
fn __repr__(&self) -> String {
format!("Constraint(id='{}', target={}, tol={})", self.inner.id(), self.inner.target_value(), self.inner.tolerance())
format!(
"Constraint(id='{}', target={}, tol={})",
self.inner.id(),
self.inner.target_value(),
self.inner.tolerance()
)
}
}
@@ -91,10 +99,18 @@ pub struct PyBoundedVariable {
impl PyBoundedVariable {
#[new]
#[pyo3(signature = (id, value, min, max, component_id=None))]
fn new(id: String, value: f64, min: f64, max: f64, component_id: Option<String>) -> PyResult<Self> {
fn new(
id: String,
value: f64,
min: f64,
max: f64,
component_id: Option<String>,
) -> PyResult<Self> {
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
let inner = match component_id {
Some(cid) => BoundedVariable::with_component(BoundedVariableId::new(id), cid, value, min, max),
Some(cid) => {
BoundedVariable::with_component(BoundedVariableId::new(id), cid, value, min, max)
}
None => BoundedVariable::new(BoundedVariableId::new(id), value, min, max),
};
match inner {
@@ -105,7 +121,13 @@ impl PyBoundedVariable {
fn __repr__(&self) -> String {
// use is_saturated if available but simpler:
format!("BoundedVariable(id='{}', value={}, bounds=[{}, {}])", self.inner.id(), self.inner.value(), self.inner.min(), self.inner.max())
format!(
"BoundedVariable(id='{}', value={}, bounds=[{}, {}])",
self.inner.id(),
self.inner.value(),
self.inner.min(),
self.inner.max()
)
}
}
@@ -159,23 +181,31 @@ impl PySystem {
/// Add a constraint to the system.
fn add_constraint(&mut self, constraint: &PyConstraint) -> PyResult<()> {
self.inner.add_constraint(constraint.inner.clone())
self.inner
.add_constraint(constraint.inner.clone())
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Add a bounded variable to the system.
fn add_bounded_variable(&mut self, variable: &PyBoundedVariable) -> PyResult<()> {
self.inner.add_bounded_variable(variable.inner.clone())
self.inner
.add_bounded_variable(variable.inner.clone())
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Link a constraint to a control variable for the inverse solver.
fn link_constraint_to_control(&mut self, constraint_id: &str, control_id: &str) -> PyResult<()> {
use entropyk_solver::inverse::{ConstraintId, BoundedVariableId};
self.inner.link_constraint_to_control(
&ConstraintId::new(constraint_id),
&BoundedVariableId::new(control_id),
).map_err(|e| PyValueError::new_err(e.to_string()))
fn link_constraint_to_control(
&mut self,
constraint_id: &str,
control_id: &str,
) -> PyResult<()> {
use entropyk_solver::inverse::{BoundedVariableId, ConstraintId};
self.inner
.link_constraint_to_control(
&ConstraintId::new(constraint_id),
&BoundedVariableId::new(control_id),
)
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Finalize the system graph: build state index mapping and validate topology.
@@ -261,13 +291,136 @@ fn extract_component(obj: &Bound<'_, PyAny>) -> PyResult<AnyPyComponent> {
fn solver_error_to_pyerr(err: entropyk_solver::SolverError) -> PyErr {
let msg = err.to_string();
match &err {
entropyk_solver::SolverError::Timeout { .. } => {
crate::errors::TimeoutError::new_err(msg)
entropyk_solver::SolverError::Timeout { .. } => crate::errors::TimeoutError::new_err(msg),
_ => crate::errors::SolverError::new_err(msg),
}
}
// =============================================================================
// Supporting Types (Story 6.6)
// =============================================================================
#[pyclass(name = "ConvergenceCriteria", module = "entropyk")]
#[derive(Clone, Default)]
pub struct PyConvergenceCriteria {
pub(crate) inner: entropyk_solver::criteria::ConvergenceCriteria,
}
#[pymethods]
impl PyConvergenceCriteria {
#[new]
#[pyo3(signature = (pressure_tolerance_pa=1.0, mass_balance_tolerance_kgs=1e-9, energy_balance_tolerance_w=1e-3))]
fn new(
pressure_tolerance_pa: f64,
mass_balance_tolerance_kgs: f64,
energy_balance_tolerance_w: f64,
) -> Self {
PyConvergenceCriteria {
inner: entropyk_solver::criteria::ConvergenceCriteria {
pressure_tolerance_pa,
mass_balance_tolerance_kgs,
energy_balance_tolerance_w,
},
}
_ => {
crate::errors::SolverError::new_err(msg)
}
#[getter]
fn pressure_tolerance_pa(&self) -> f64 {
self.inner.pressure_tolerance_pa
}
#[getter]
fn mass_balance_tolerance_kgs(&self) -> f64 {
self.inner.mass_balance_tolerance_kgs
}
#[getter]
fn energy_balance_tolerance_w(&self) -> f64 {
self.inner.energy_balance_tolerance_w
}
fn __repr__(&self) -> String {
format!(
"ConvergenceCriteria(dP={:.1e} Pa, dM={:.1e} kg/s, dE={:.1e} W)",
self.inner.pressure_tolerance_pa,
self.inner.mass_balance_tolerance_kgs,
self.inner.energy_balance_tolerance_w
)
}
}
#[pyclass(name = "JacobianFreezingConfig", module = "entropyk")]
#[derive(Clone, Default)]
pub struct PyJacobianFreezingConfig {
pub(crate) inner: entropyk_solver::solver::JacobianFreezingConfig,
}
#[pymethods]
impl PyJacobianFreezingConfig {
#[new]
#[pyo3(signature = (max_frozen_iters=3, threshold=0.1))]
fn new(max_frozen_iters: usize, threshold: f64) -> Self {
PyJacobianFreezingConfig {
inner: entropyk_solver::solver::JacobianFreezingConfig {
max_frozen_iters,
threshold,
},
}
}
#[getter]
fn max_frozen_iters(&self) -> usize {
self.inner.max_frozen_iters
}
#[getter]
fn threshold(&self) -> f64 {
self.inner.threshold
}
fn __repr__(&self) -> String {
format!(
"JacobianFreezingConfig(max_iters={}, threshold={:.2})",
self.inner.max_frozen_iters, self.inner.threshold
)
}
}
#[pyclass(name = "TimeoutConfig", module = "entropyk")]
#[derive(Clone, Default)]
pub struct PyTimeoutConfig {
pub(crate) inner: entropyk_solver::solver::TimeoutConfig,
}
#[pymethods]
impl PyTimeoutConfig {
#[new]
#[pyo3(signature = (return_best_state_on_timeout=true, zoh_fallback=false))]
fn new(return_best_state_on_timeout: bool, zoh_fallback: bool) -> Self {
PyTimeoutConfig {
inner: entropyk_solver::solver::TimeoutConfig {
return_best_state_on_timeout,
zoh_fallback,
},
}
}
#[getter]
fn return_best_state_on_timeout(&self) -> bool {
self.inner.return_best_state_on_timeout
}
#[getter]
fn zoh_fallback(&self) -> bool {
self.inner.zoh_fallback
}
fn __repr__(&self) -> String {
format!(
"TimeoutConfig(return_best={}, zoh={})",
self.inner.return_best_state_on_timeout, self.inner.zoh_fallback
)
}
}
// =============================================================================
@@ -281,30 +434,90 @@ fn solver_error_to_pyerr(err: entropyk_solver::SolverError) -> PyErr {
/// config = NewtonConfig(max_iterations=100, tolerance=1e-6)
/// result = config.solve(system)
#[pyclass(name = "NewtonConfig", module = "entropyk")]
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct PyNewtonConfig {
#[pyo3(get, set)]
pub(crate) max_iterations: usize,
#[pyo3(get, set)]
pub(crate) tolerance: f64,
#[pyo3(get, set)]
pub(crate) line_search: bool,
#[pyo3(get, set)]
pub(crate) timeout_ms: Option<u64>,
#[pyo3(get, set)]
pub(crate) initial_state: Option<Vec<f64>>,
#[pyo3(get, set)]
pub(crate) use_numerical_jacobian: bool,
#[pyo3(get, set)]
pub(crate) jacobian_freezing: Option<PyJacobianFreezingConfig>,
#[pyo3(get, set)]
pub(crate) convergence_criteria: Option<PyConvergenceCriteria>,
#[pyo3(get, set)]
pub(crate) timeout_config: PyTimeoutConfig,
#[pyo3(get, set)]
pub(crate) previous_state: Option<Vec<f64>>,
#[pyo3(get, set)]
pub(crate) line_search_armijo_c: f64,
#[pyo3(get, set)]
pub(crate) line_search_max_backtracks: usize,
#[pyo3(get, set)]
pub(crate) divergence_threshold: f64,
}
#[pymethods]
impl PyNewtonConfig {
#[new]
#[pyo3(signature = (max_iterations=100, tolerance=1e-6, line_search=false, timeout_ms=None))]
#[pyo3(signature = (
max_iterations=100,
tolerance=1e-6,
line_search=false,
timeout_ms=None,
initial_state=None,
use_numerical_jacobian=false,
jacobian_freezing=None,
convergence_criteria=None,
timeout_config=None,
previous_state=None,
line_search_armijo_c=1e-4,
line_search_max_backtracks=20,
divergence_threshold=1e10
))]
fn new(
max_iterations: usize,
tolerance: f64,
line_search: bool,
timeout_ms: Option<u64>,
) -> Self {
PyNewtonConfig {
initial_state: Option<Vec<f64>>,
use_numerical_jacobian: bool,
jacobian_freezing: Option<PyJacobianFreezingConfig>,
convergence_criteria: Option<PyConvergenceCriteria>,
timeout_config: Option<PyTimeoutConfig>,
previous_state: Option<Vec<f64>>,
line_search_armijo_c: f64,
line_search_max_backtracks: usize,
divergence_threshold: f64,
) -> PyResult<Self> {
if tolerance <= 0.0 {
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"));
}
Ok(PyNewtonConfig {
max_iterations,
tolerance,
line_search,
timeout_ms,
}
initial_state,
use_numerical_jacobian,
jacobian_freezing,
convergence_criteria,
timeout_config: timeout_config.unwrap_or_default(),
previous_state,
line_search_armijo_c,
line_search_max_backtracks,
divergence_threshold,
})
}
/// Solve the system. Returns a ConvergedState on success.
@@ -326,14 +539,22 @@ impl PyNewtonConfig {
tolerance: self.tolerance,
line_search: self.line_search,
timeout: self.timeout_ms.map(Duration::from_millis),
..Default::default()
use_numerical_jacobian: self.use_numerical_jacobian,
line_search_armijo_c: self.line_search_armijo_c,
line_search_max_backtracks: self.line_search_max_backtracks,
divergence_threshold: self.divergence_threshold,
timeout_config: self.timeout_config.inner.clone(),
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()),
jacobian_freezing: self.jacobian_freezing.as_ref().map(|jf| jf.inner.clone()),
};
// Catch any Rust panic to prevent it from reaching Python (Task 5.4)
use entropyk_solver::Solver;
let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
config.solve(&mut system.inner)
}));
let solve_result =
panic::catch_unwind(panic::AssertUnwindSafe(|| config.solve(&mut system.inner)));
match solve_result {
Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
@@ -363,18 +584,44 @@ impl PyNewtonConfig {
/// config = PicardConfig(max_iterations=500, tolerance=1e-4)
/// result = config.solve(system)
#[pyclass(name = "PicardConfig", module = "entropyk")]
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct PyPicardConfig {
#[pyo3(get, set)]
pub(crate) max_iterations: usize,
#[pyo3(get, set)]
pub(crate) tolerance: f64,
#[pyo3(get, set)]
pub(crate) relaxation: f64,
#[pyo3(get, set)]
pub(crate) initial_state: Option<Vec<f64>>,
#[pyo3(get, set)]
pub(crate) timeout_ms: Option<u64>,
#[pyo3(get, set)]
pub(crate) convergence_criteria: Option<PyConvergenceCriteria>,
}
#[pymethods]
impl PyPicardConfig {
#[new]
#[pyo3(signature = (max_iterations=500, tolerance=1e-4, relaxation=0.5))]
fn new(max_iterations: usize, tolerance: f64, relaxation: f64) -> PyResult<Self> {
#[pyo3(signature = (
max_iterations=500,
tolerance=1e-4,
relaxation=0.5,
initial_state=None,
timeout_ms=None,
convergence_criteria=None
))]
fn new(
max_iterations: usize,
tolerance: f64,
relaxation: f64,
initial_state: Option<Vec<f64>>,
timeout_ms: Option<u64>,
convergence_criteria: Option<PyConvergenceCriteria>,
) -> PyResult<Self> {
if tolerance <= 0.0 {
return Err(PyValueError::new_err("tolerance must be greater than 0"));
}
if !(0.0..=1.0).contains(&relaxation) {
return Err(PyValueError::new_err(
"relaxation must be between 0.0 and 1.0",
@@ -384,6 +631,9 @@ impl PyPicardConfig {
max_iterations,
tolerance,
relaxation,
initial_state,
timeout_ms,
convergence_criteria,
})
}
@@ -404,13 +654,15 @@ impl PyPicardConfig {
max_iterations: self.max_iterations,
tolerance: self.tolerance,
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()),
..Default::default()
};
use entropyk_solver::Solver;
let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
config.solve(&mut system.inner)
}));
let solve_result =
panic::catch_unwind(panic::AssertUnwindSafe(|| config.solve(&mut system.inner)));
match solve_result {
Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
@@ -454,8 +706,8 @@ 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)),
picard: picard.unwrap_or_else(|| PyPicardConfig::new(500, 1e-4, 0.5).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()),
})
}
@@ -477,19 +729,30 @@ impl PyFallbackConfig {
tolerance: self.newton.tolerance,
line_search: self.newton.line_search,
timeout: self.newton.timeout_ms.map(Duration::from_millis),
..Default::default()
use_numerical_jacobian: self.newton.use_numerical_jacobian,
line_search_armijo_c: self.newton.line_search_armijo_c,
line_search_max_backtracks: self.newton.line_search_max_backtracks,
divergence_threshold: self.newton.divergence_threshold,
timeout_config: self.newton.timeout_config.inner.clone(),
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()),
};
let picard_config = entropyk_solver::PicardConfig {
max_iterations: self.picard.max_iterations,
tolerance: self.picard.tolerance,
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()),
..Default::default()
};
let mut fallback = entropyk_solver::FallbackSolver::new(
entropyk_solver::FallbackConfig::default(),
)
.with_newton_config(newton_config)
.with_picard_config(picard_config);
let mut fallback =
entropyk_solver::FallbackSolver::new(entropyk_solver::FallbackConfig::default())
.with_newton_config(newton_config)
.with_picard_config(picard_config);
use entropyk_solver::Solver;
let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
@@ -545,7 +808,9 @@ impl PyConvergenceStatus {
match &self.inner {
entropyk_solver::ConvergenceStatus::Converged => "Converged".to_string(),
entropyk_solver::ConvergenceStatus::TimedOutWithBestState => "TimedOut".to_string(),
entropyk_solver::ConvergenceStatus::ControlSaturation => "ControlSaturation".to_string(),
entropyk_solver::ConvergenceStatus::ControlSaturation => {
"ControlSaturation".to_string()
}
}
}
@@ -557,7 +822,10 @@ impl PyConvergenceStatus {
entropyk_solver::ConvergenceStatus::TimedOutWithBestState
),
"ControlSaturation" => {
matches!(self.inner, entropyk_solver::ConvergenceStatus::ControlSaturation)
matches!(
self.inner,
entropyk_solver::ConvergenceStatus::ControlSaturation
)
}
_ => false,
}
@@ -649,3 +917,131 @@ impl PyConvergedState {
Ok(numpy::PyArray1::from_vec(py, self.state.clone()))
}
}
// =============================================================================
// SolverStrategy
// =============================================================================
#[pyclass(name = "SolverStrategy", module = "entropyk")]
#[derive(Clone)]
pub struct PySolverStrategy {
pub(crate) inner: entropyk_solver::SolverStrategy,
}
#[pymethods]
impl PySolverStrategy {
#[staticmethod]
#[pyo3(signature = (
max_iterations=100,
tolerance=1e-6,
line_search=false,
timeout_ms=None,
initial_state=None,
use_numerical_jacobian=false,
jacobian_freezing=None,
convergence_criteria=None,
timeout_config=None,
previous_state=None,
line_search_armijo_c=1e-4,
line_search_max_backtracks=20,
divergence_threshold=1e10
))]
fn newton(
max_iterations: usize,
tolerance: f64,
line_search: bool,
timeout_ms: Option<u64>,
initial_state: Option<Vec<f64>>,
use_numerical_jacobian: bool,
jacobian_freezing: Option<PyJacobianFreezingConfig>,
convergence_criteria: Option<PyConvergenceCriteria>,
timeout_config: Option<PyTimeoutConfig>,
previous_state: Option<Vec<f64>>,
line_search_armijo_c: f64,
line_search_max_backtracks: usize,
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,
)?;
let config = entropyk_solver::NewtonConfig {
max_iterations: py_config.max_iterations,
tolerance: py_config.tolerance,
line_search: py_config.line_search,
timeout: py_config.timeout_ms.map(Duration::from_millis),
use_numerical_jacobian: py_config.use_numerical_jacobian,
line_search_armijo_c: py_config.line_search_armijo_c,
line_search_max_backtracks: py_config.line_search_max_backtracks,
divergence_threshold: py_config.divergence_threshold,
timeout_config: py_config.timeout_config.inner.clone(),
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()),
};
Ok(PySolverStrategy {
inner: entropyk_solver::SolverStrategy::NewtonRaphson(config),
})
}
#[staticmethod]
#[pyo3(signature = (
max_iterations=500,
tolerance=1e-4,
relaxation=0.5,
initial_state=None,
timeout_ms=None,
convergence_criteria=None
))]
fn picard(
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
)?;
let config = entropyk_solver::PicardConfig {
max_iterations: py_config.max_iterations,
tolerance: py_config.tolerance,
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()),
..Default::default()
};
Ok(PySolverStrategy {
inner: entropyk_solver::SolverStrategy::SequentialSubstitution(config),
})
}
#[staticmethod]
fn default() -> Self {
PySolverStrategy {
inner: entropyk_solver::SolverStrategy::default(),
}
}
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)
}));
match solve_result {
Ok(Ok(converged)) => Ok(PyConvergedState::from_rust(converged)),
Ok(Err(e)) => Err(solver_error_to_pyerr(e)),
Err(_) => Err(PyRuntimeError::new_err(
"Internal error: solver panicked. This is a bug — please report it.",
)),
}
}
}

View File

@@ -30,7 +30,12 @@ impl PyPressure {
/// Create a Pressure. Specify exactly one of: ``pa``, ``bar``, ``kpa``, ``psi``.
#[new]
#[pyo3(signature = (pa=None, bar=None, kpa=None, psi=None))]
fn new(pa: Option<f64>, bar: Option<f64>, kpa: Option<f64>, psi: Option<f64>) -> PyResult<Self> {
fn new(
pa: Option<f64>,
bar: Option<f64>,
kpa: Option<f64>,
psi: Option<f64>,
) -> PyResult<Self> {
let value = match (pa, bar, kpa, psi) {
(Some(v), None, None, None) => v,
(None, Some(v), None, None) => v * 100_000.0,