chore: sync project state and current artifacts
This commit is contained in:
@@ -1,13 +1,8 @@
|
||||
//! Python wrappers for Entropyk thermodynamic components.
|
||||
//!
|
||||
//! Components are wrapped with simplified Pythonic constructors.
|
||||
//! Type-state–based 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 (0–1).
|
||||
#[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 (0–1), None if fully open.
|
||||
/// Valve opening (0–1).
|
||||
#[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'",
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user