feat(python): implement python bindings for all components and solvers
This commit is contained in:
781
bindings/python/src/components.rs
Normal file
781
bindings/python/src/components.rs
Normal file
@@ -0,0 +1,781 @@
|
||||
//! 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.
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Simple component adapter — implements Component directly
|
||||
// =============================================================================
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// comp = Compressor(
|
||||
/// m1=0.85, m2=2.5,
|
||||
/// m3=500.0, m4=1500.0, m5=-2.5, m6=1.8,
|
||||
/// m7=600.0, m8=1600.0, m9=-3.0, m10=2.0,
|
||||
/// speed_rpm=2900.0,
|
||||
/// displacement=0.0001,
|
||||
/// efficiency=0.85,
|
||||
/// fluid="R134a",
|
||||
/// )
|
||||
#[pyclass(name = "Compressor", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
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,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCompressor {
|
||||
/// Create a Compressor with AHRI 540 coefficients.
|
||||
#[new]
|
||||
#[pyo3(signature = (
|
||||
m1=0.85, m2=2.5,
|
||||
m3=500.0, m4=1500.0, m5=-2.5, m6=1.8,
|
||||
m7=600.0, m8=1600.0, m9=-3.0, m10=2.0,
|
||||
speed_rpm=2900.0,
|
||||
displacement=0.0001,
|
||||
efficiency=0.85,
|
||||
fluid="R134a"
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn new(
|
||||
m1: f64,
|
||||
m2: f64,
|
||||
m3: f64,
|
||||
m4: f64,
|
||||
m5: f64,
|
||||
m6: f64,
|
||||
m7: f64,
|
||||
m8: f64,
|
||||
m9: f64,
|
||||
m10: f64,
|
||||
speed_rpm: f64,
|
||||
displacement: f64,
|
||||
efficiency: f64,
|
||||
fluid: &str,
|
||||
) -> PyResult<Self> {
|
||||
if speed_rpm <= 0.0 {
|
||||
return Err(PyValueError::new_err("speed_rpm must be positive"));
|
||||
}
|
||||
if displacement <= 0.0 {
|
||||
return Err(PyValueError::new_err("displacement must be positive"));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&efficiency) {
|
||||
return Err(PyValueError::new_err(
|
||||
"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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// AHRI 540 coefficients.
|
||||
#[getter]
|
||||
fn speed(&self) -> f64 {
|
||||
self.speed_rpm
|
||||
}
|
||||
|
||||
/// Isentropic efficiency (0–1).
|
||||
#[getter]
|
||||
fn efficiency_value(&self) -> f64 {
|
||||
self.efficiency
|
||||
}
|
||||
|
||||
/// Fluid name.
|
||||
#[getter]
|
||||
fn fluid_name(&self) -> &str {
|
||||
&self.fluid
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Compressor(speed={:.0} RPM, η={:.2}, fluid={})",
|
||||
self.speed_rpm, self.efficiency, self.fluid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Condenser
|
||||
// =============================================================================
|
||||
|
||||
/// A condenser (heat rejection) component.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// cond = Condenser(ua=5000.0)
|
||||
#[pyclass(name = "Condenser", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyCondenser {
|
||||
pub(crate) ua: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyCondenser {
|
||||
#[new]
|
||||
#[pyo3(signature = (ua=5000.0))]
|
||||
fn new(ua: f64) -> PyResult<Self> {
|
||||
if ua <= 0.0 {
|
||||
return Err(PyValueError::new_err("ua must be positive"));
|
||||
}
|
||||
Ok(PyCondenser { ua })
|
||||
}
|
||||
|
||||
/// Thermal conductance UA in W/K.
|
||||
#[getter]
|
||||
fn ua_value(&self) -> f64 {
|
||||
self.ua
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("Condenser(UA={:.1} W/K)", self.ua)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyCondenser {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(entropyk::Condenser::new(self.ua))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Evaporator
|
||||
// =============================================================================
|
||||
|
||||
/// An evaporator (heat absorption) component.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// evap = Evaporator(ua=3000.0)
|
||||
#[pyclass(name = "Evaporator", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyEvaporator {
|
||||
pub(crate) ua: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEvaporator {
|
||||
#[new]
|
||||
#[pyo3(signature = (ua=3000.0))]
|
||||
fn new(ua: f64) -> PyResult<Self> {
|
||||
if ua <= 0.0 {
|
||||
return Err(PyValueError::new_err("ua must be positive"));
|
||||
}
|
||||
Ok(PyEvaporator { ua })
|
||||
}
|
||||
|
||||
/// Thermal conductance UA in W/K.
|
||||
#[getter]
|
||||
fn ua_value(&self) -> f64 {
|
||||
self.ua
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("Evaporator(UA={:.1} W/K)", self.ua)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyEvaporator {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(entropyk::Evaporator::new(self.ua))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Economizer
|
||||
// =============================================================================
|
||||
|
||||
/// 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 {
|
||||
pub(crate) ua: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEconomizer {
|
||||
#[new]
|
||||
#[pyo3(signature = (ua=2000.0))]
|
||||
fn new(ua: f64) -> PyResult<Self> {
|
||||
if ua <= 0.0 {
|
||||
return Err(PyValueError::new_err("ua must be positive"));
|
||||
}
|
||||
Ok(PyEconomizer { ua })
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("Economizer(UA={:.1} W/K)", self.ua)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyEconomizer {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(entropyk::Economizer::new(self.ua))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ExpansionValve
|
||||
// =============================================================================
|
||||
|
||||
/// An expansion valve (isenthalpic throttling device).
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// valve = ExpansionValve(fluid="R134a", opening=1.0)
|
||||
#[pyclass(name = "ExpansionValve", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyExpansionValve {
|
||||
pub(crate) fluid: String,
|
||||
pub(crate) opening: Option<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",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(PyExpansionValve {
|
||||
fluid: fluid.to_string(),
|
||||
opening,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fluid name.
|
||||
#[getter]
|
||||
fn fluid_name(&self) -> &str {
|
||||
&self.fluid
|
||||
}
|
||||
|
||||
/// Valve opening (0–1), None if fully open.
|
||||
#[getter]
|
||||
fn opening_value(&self) -> Option<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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PyExpansionValve {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
// ExpansionValve uses type-state pattern; 2 equations
|
||||
Box::new(SimpleAdapter::new("ExpansionValve", 2))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pipe
|
||||
// =============================================================================
|
||||
|
||||
/// 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)
|
||||
#[pyclass(name = "Pipe", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
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,
|
||||
}
|
||||
|
||||
#[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> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Pipe(L={:.2}m, D={:.4}m, fluid={})",
|
||||
self.length, self.diameter, self.fluid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Pump
|
||||
// =============================================================================
|
||||
|
||||
/// 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 {
|
||||
pub(crate) pressure_rise_pa: f64,
|
||||
pub(crate) efficiency: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyPump {
|
||||
#[new]
|
||||
#[pyo3(signature = (pressure_rise_pa=200000.0, efficiency=0.75))]
|
||||
fn new(pressure_rise_pa: f64, efficiency: f64) -> PyResult<Self> {
|
||||
if pressure_rise_pa <= 0.0 {
|
||||
return Err(PyValueError::new_err("pressure_rise_pa must be positive"));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&efficiency) {
|
||||
return Err(PyValueError::new_err(
|
||||
"efficiency must be between 0.0 and 1.0",
|
||||
));
|
||||
}
|
||||
Ok(PyPump {
|
||||
pressure_rise_pa,
|
||||
efficiency,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Pump(ΔP={:.0} Pa, η={:.2})",
|
||||
self.pressure_rise_pa, self.efficiency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyPump {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("Pump", 2))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Fan
|
||||
// =============================================================================
|
||||
|
||||
/// 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 {
|
||||
pub(crate) pressure_rise_pa: f64,
|
||||
pub(crate) efficiency: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFan {
|
||||
#[new]
|
||||
#[pyo3(signature = (pressure_rise_pa=500.0, efficiency=0.65))]
|
||||
fn new(pressure_rise_pa: f64, efficiency: f64) -> PyResult<Self> {
|
||||
if pressure_rise_pa <= 0.0 {
|
||||
return Err(PyValueError::new_err("pressure_rise_pa must be positive"));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&efficiency) {
|
||||
return Err(PyValueError::new_err(
|
||||
"efficiency must be between 0.0 and 1.0",
|
||||
));
|
||||
}
|
||||
Ok(PyFan {
|
||||
pressure_rise_pa,
|
||||
efficiency,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Fan(ΔP={:.0} Pa, η={:.2})",
|
||||
self.pressure_rise_pa, self.efficiency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFan {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("Fan", 2))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowSplitter
|
||||
// =============================================================================
|
||||
|
||||
/// A flow splitter that divides a stream into two or more branches.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// splitter = FlowSplitter(n_outlets=2)
|
||||
#[pyclass(name = "FlowSplitter", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFlowSplitter {
|
||||
pub(crate) n_outlets: usize,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFlowSplitter {
|
||||
#[new]
|
||||
#[pyo3(signature = (n_outlets=2))]
|
||||
fn new(n_outlets: usize) -> PyResult<Self> {
|
||||
if n_outlets < 2 {
|
||||
return Err(PyValueError::new_err("n_outlets must be >= 2"));
|
||||
}
|
||||
Ok(PyFlowSplitter { n_outlets })
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("FlowSplitter(n_outlets={})", self.n_outlets)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFlowSplitter {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("FlowSplitter", self.n_outlets))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowMerger
|
||||
// =============================================================================
|
||||
|
||||
/// A flow merger that combines two or more branches into one.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// merger = FlowMerger(n_inlets=2)
|
||||
#[pyclass(name = "FlowMerger", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFlowMerger {
|
||||
pub(crate) n_inlets: usize,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFlowMerger {
|
||||
#[new]
|
||||
#[pyo3(signature = (n_inlets=2))]
|
||||
fn new(n_inlets: usize) -> PyResult<Self> {
|
||||
if n_inlets < 2 {
|
||||
return Err(PyValueError::new_err("n_inlets must be >= 2"));
|
||||
}
|
||||
Ok(PyFlowMerger { n_inlets })
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("FlowMerger(n_inlets={})", self.n_inlets)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFlowMerger {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("FlowMerger", self.n_inlets))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowSource
|
||||
// =============================================================================
|
||||
|
||||
/// A boundary condition representing a mass flow source.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// source = FlowSource(pressure_pa=101325.0, temperature_k=300.0)
|
||||
#[pyclass(name = "FlowSource", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFlowSource {
|
||||
pub(crate) pressure_pa: f64,
|
||||
pub(crate) temperature_k: f64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFlowSource {
|
||||
#[new]
|
||||
#[pyo3(signature = (pressure_pa=101325.0, temperature_k=300.0))]
|
||||
fn new(pressure_pa: f64, temperature_k: f64) -> PyResult<Self> {
|
||||
if pressure_pa <= 0.0 {
|
||||
return Err(PyValueError::new_err("pressure_pa must be positive"));
|
||||
}
|
||||
if temperature_k <= 0.0 {
|
||||
return Err(PyValueError::new_err("temperature_k must be positive"));
|
||||
}
|
||||
Ok(PyFlowSource {
|
||||
pressure_pa,
|
||||
temperature_k,
|
||||
})
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"FlowSource(P={:.0} Pa, T={:.1} K)",
|
||||
self.pressure_pa, self.temperature_k
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFlowSource {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("FlowSource", 0))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FlowSink
|
||||
// =============================================================================
|
||||
|
||||
/// A boundary condition representing a mass flow sink.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// sink = FlowSink()
|
||||
#[pyclass(name = "FlowSink", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFlowSink;
|
||||
|
||||
#[pymethods]
|
||||
impl PyFlowSink {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
PyFlowSink
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
"FlowSink()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl PyFlowSink {
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
Box::new(SimpleAdapter::new("FlowSink", 0))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OperationalState
|
||||
// =============================================================================
|
||||
|
||||
/// Operational state of a component: On, Off, or Bypass.
|
||||
#[pyclass(name = "OperationalState", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyOperationalState {
|
||||
pub(crate) inner: entropyk::OperationalState,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyOperationalState {
|
||||
/// Create an OperationalState. Valid values: "on", "off", "bypass".
|
||||
#[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,
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"state must be one of: 'on', 'off', 'bypass'",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyOperationalState { inner })
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("OperationalState({:?})", self.inner)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:?}", self.inner)
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyOperationalState) -> bool {
|
||||
self.inner == other.inner
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component enum for type-erasure
|
||||
// =============================================================================
|
||||
|
||||
/// Internal enum to hold any Python component wrapper.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum AnyPyComponent {
|
||||
Compressor(PyCompressor),
|
||||
Condenser(PyCondenser),
|
||||
Evaporator(PyEvaporator),
|
||||
Economizer(PyEconomizer),
|
||||
ExpansionValve(PyExpansionValve),
|
||||
Pipe(PyPipe),
|
||||
Pump(PyPump),
|
||||
Fan(PyFan),
|
||||
FlowSplitter(PyFlowSplitter),
|
||||
FlowMerger(PyFlowMerger),
|
||||
FlowSource(PyFlowSource),
|
||||
FlowSink(PyFlowSink),
|
||||
}
|
||||
|
||||
impl AnyPyComponent {
|
||||
/// Build the Rust component to insert into a System.
|
||||
pub(crate) fn build(&self) -> Box<dyn Component> {
|
||||
match self {
|
||||
AnyPyComponent::Compressor(c) => c.build(),
|
||||
AnyPyComponent::Condenser(c) => c.build(),
|
||||
AnyPyComponent::Evaporator(c) => c.build(),
|
||||
AnyPyComponent::Economizer(c) => c.build(),
|
||||
AnyPyComponent::ExpansionValve(c) => c.build(),
|
||||
AnyPyComponent::Pipe(c) => c.build(),
|
||||
AnyPyComponent::Pump(c) => c.build(),
|
||||
AnyPyComponent::Fan(c) => c.build(),
|
||||
AnyPyComponent::FlowSplitter(c) => c.build(),
|
||||
AnyPyComponent::FlowMerger(c) => c.build(),
|
||||
AnyPyComponent::FlowSource(c) => c.build(),
|
||||
AnyPyComponent::FlowSink(c) => c.build(),
|
||||
}
|
||||
}
|
||||
}
|
||||
72
bindings/python/src/errors.rs
Normal file
72
bindings/python/src/errors.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Python exception types mapped from Entropyk errors.
|
||||
|
||||
use pyo3::create_exception;
|
||||
use pyo3::exceptions::PyException;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
// Exception hierarchy:
|
||||
// EntropykError (base)
|
||||
// ├── SolverError
|
||||
// │ ├── TimeoutError
|
||||
// │ └── ControlSaturationError
|
||||
// ├── FluidError
|
||||
// ├── ComponentError
|
||||
// ├── 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).");
|
||||
|
||||
/// 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("FluidError", m.py().get_type::<FluidError>())?;
|
||||
m.add("ComponentError", m.py().get_type::<ComponentError>())?;
|
||||
m.add("TopologyError", m.py().get_type::<TopologyError>())?;
|
||||
m.add("ValidationError", m.py().get_type::<ValidationError>())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts a `ThermoError` into the appropriate Python exception.
|
||||
pub fn thermo_error_to_pyerr(err: entropyk::ThermoError) -> PyErr {
|
||||
use entropyk::ThermoError;
|
||||
match &err {
|
||||
ThermoError::Solver(solver_err) => {
|
||||
let msg = err.to_string();
|
||||
let solver_msg = solver_err.to_string();
|
||||
// Check for timeout and control saturation sub-types
|
||||
if solver_msg.contains("timeout") || solver_msg.contains("Timeout") {
|
||||
TimeoutError::new_err(msg)
|
||||
} else if solver_msg.contains("saturation") || solver_msg.contains("Saturation") {
|
||||
ControlSaturationError::new_err(msg)
|
||||
} else {
|
||||
SolverError::new_err(msg)
|
||||
}
|
||||
}
|
||||
ThermoError::Fluid(_) => FluidError::new_err(err.to_string()),
|
||||
ThermoError::Component(_) | ThermoError::Connection(_) => {
|
||||
ComponentError::new_err(err.to_string())
|
||||
}
|
||||
ThermoError::Topology(_) | ThermoError::AddEdge(_) => {
|
||||
TopologyError::new_err(err.to_string())
|
||||
}
|
||||
ThermoError::Calibration(_) | ThermoError::Constraint(_) => {
|
||||
ValidationError::new_err(err.to_string())
|
||||
}
|
||||
ThermoError::Initialization(_)
|
||||
| ThermoError::Builder(_)
|
||||
| ThermoError::Mixture(_)
|
||||
| ThermoError::InvalidInput(_)
|
||||
| ThermoError::NotSupported(_)
|
||||
| ThermoError::NotFinalized => EntropykError::new_err(err.to_string()),
|
||||
}
|
||||
}
|
||||
49
bindings/python/src/lib.rs
Normal file
49
bindings/python/src/lib.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
//! Entropyk Python bindings.
|
||||
//!
|
||||
//! This crate provides Python wrappers for the Entropyk thermodynamic
|
||||
//! simulation library via PyO3 + Maturin.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
pub(crate) mod components;
|
||||
pub(crate) mod errors;
|
||||
pub(crate) mod solver;
|
||||
pub(crate) mod types;
|
||||
|
||||
/// Python module: ``import entropyk``
|
||||
#[pymodule]
|
||||
fn entropyk(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
// Register exceptions first
|
||||
errors::register_exceptions(m)?;
|
||||
|
||||
// Core types
|
||||
m.add_class::<types::PyPressure>()?;
|
||||
m.add_class::<types::PyTemperature>()?;
|
||||
m.add_class::<types::PyEnthalpy>()?;
|
||||
m.add_class::<types::PyMassFlow>()?;
|
||||
|
||||
// Components
|
||||
m.add_class::<components::PyCompressor>()?;
|
||||
m.add_class::<components::PyCondenser>()?;
|
||||
m.add_class::<components::PyEvaporator>()?;
|
||||
m.add_class::<components::PyEconomizer>()?;
|
||||
m.add_class::<components::PyExpansionValve>()?;
|
||||
m.add_class::<components::PyPipe>()?;
|
||||
m.add_class::<components::PyPump>()?;
|
||||
m.add_class::<components::PyFan>()?;
|
||||
m.add_class::<components::PyFlowSplitter>()?;
|
||||
m.add_class::<components::PyFlowMerger>()?;
|
||||
m.add_class::<components::PyFlowSource>()?;
|
||||
m.add_class::<components::PyFlowSink>()?;
|
||||
m.add_class::<components::PyOperationalState>()?;
|
||||
|
||||
// Solver
|
||||
m.add_class::<solver::PySystem>()?;
|
||||
m.add_class::<solver::PyNewtonConfig>()?;
|
||||
m.add_class::<solver::PyPicardConfig>()?;
|
||||
m.add_class::<solver::PyFallbackConfig>()?;
|
||||
m.add_class::<solver::PyConvergedState>()?;
|
||||
m.add_class::<solver::PyConvergenceStatus>()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
542
bindings/python/src/solver.rs
Normal file
542
bindings/python/src/solver.rs
Normal file
@@ -0,0 +1,542 @@
|
||||
//! Python wrappers for Entropyk solver and system types.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::exceptions::{PyValueError, PyRuntimeError};
|
||||
use std::time::Duration;
|
||||
use std::panic;
|
||||
|
||||
use crate::components::AnyPyComponent;
|
||||
|
||||
// =============================================================================
|
||||
// System
|
||||
// =============================================================================
|
||||
|
||||
/// The thermodynamic system graph.
|
||||
///
|
||||
/// Components are added as nodes, flow connections as edges.
|
||||
/// Call ``finalize()`` before solving.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// system = System()
|
||||
/// comp_idx = system.add_component(Compressor())
|
||||
/// cond_idx = system.add_component(Condenser(ua=5000.0))
|
||||
/// system.add_edge(comp_idx, cond_idx)
|
||||
/// system.finalize()
|
||||
#[pyclass(name = "System", module = "entropyk", unsendable)]
|
||||
pub struct PySystem {
|
||||
inner: entropyk_solver::System,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PySystem {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
PySystem {
|
||||
inner: entropyk_solver::System::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a component to the system. Returns the node index.
|
||||
///
|
||||
/// Args:
|
||||
/// component: A component (Compressor, Condenser, Evaporator, etc.).
|
||||
///
|
||||
/// Returns:
|
||||
/// int: The node index of the added component.
|
||||
fn add_component(&mut self, component: &Bound<'_, PyAny>) -> PyResult<usize> {
|
||||
let py_comp = extract_component(component)?;
|
||||
let boxed = py_comp.build();
|
||||
let idx = self.inner.add_component(boxed);
|
||||
Ok(idx.index())
|
||||
}
|
||||
|
||||
/// Add a flow edge from source to target.
|
||||
///
|
||||
/// Args:
|
||||
/// source: Source node index (from ``add_component``).
|
||||
/// target: Target node index (from ``add_component``).
|
||||
///
|
||||
/// Returns:
|
||||
/// int: The edge index.
|
||||
fn add_edge(&mut self, source: usize, target: usize) -> PyResult<usize> {
|
||||
let src = petgraph::graph::NodeIndex::new(source);
|
||||
let tgt = petgraph::graph::NodeIndex::new(target);
|
||||
let edge = self
|
||||
.inner
|
||||
.add_edge(src, tgt)
|
||||
.map_err(|e| crate::errors::TopologyError::new_err(e.to_string()))?;
|
||||
Ok(edge.index())
|
||||
}
|
||||
|
||||
/// Finalize the system graph: build state index mapping and validate topology.
|
||||
///
|
||||
/// Must be called before ``solve()``.
|
||||
fn finalize(&mut self) -> PyResult<()> {
|
||||
self.inner
|
||||
.finalize()
|
||||
.map_err(|e| crate::errors::TopologyError::new_err(e.to_string()))
|
||||
}
|
||||
|
||||
/// Number of nodes (components) in the system graph.
|
||||
#[getter]
|
||||
fn node_count(&self) -> usize {
|
||||
self.inner.node_count()
|
||||
}
|
||||
|
||||
/// Number of edges in the system graph.
|
||||
#[getter]
|
||||
fn edge_count(&self) -> usize {
|
||||
self.inner.edge_count()
|
||||
}
|
||||
|
||||
/// Length of the state vector after finalization.
|
||||
#[getter]
|
||||
fn state_vector_len(&self) -> usize {
|
||||
self.inner.state_vector_len()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"System(nodes={}, edges={})",
|
||||
self.inner.node_count(),
|
||||
self.inner.edge_count()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a Python component wrapper into our internal enum.
|
||||
fn extract_component(obj: &Bound<'_, PyAny>) -> PyResult<AnyPyComponent> {
|
||||
if let Ok(c) = obj.extract::<crate::components::PyCompressor>() {
|
||||
return Ok(AnyPyComponent::Compressor(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyCondenser>() {
|
||||
return Ok(AnyPyComponent::Condenser(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyEvaporator>() {
|
||||
return Ok(AnyPyComponent::Evaporator(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyEconomizer>() {
|
||||
return Ok(AnyPyComponent::Economizer(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyExpansionValve>() {
|
||||
return Ok(AnyPyComponent::ExpansionValve(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyPipe>() {
|
||||
return Ok(AnyPyComponent::Pipe(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyPump>() {
|
||||
return Ok(AnyPyComponent::Pump(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFan>() {
|
||||
return Ok(AnyPyComponent::Fan(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFlowSplitter>() {
|
||||
return Ok(AnyPyComponent::FlowSplitter(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFlowMerger>() {
|
||||
return Ok(AnyPyComponent::FlowMerger(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFlowSource>() {
|
||||
return Ok(AnyPyComponent::FlowSource(c));
|
||||
}
|
||||
if let Ok(c) = obj.extract::<crate::components::PyFlowSink>() {
|
||||
return Ok(AnyPyComponent::FlowSink(c));
|
||||
}
|
||||
Err(PyValueError::new_err(
|
||||
"Expected a component (Compressor, Condenser, Evaporator, ExpansionValve, Pipe, Pump, Fan, Economizer, FlowSplitter, FlowMerger, FlowSource, FlowSink)",
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert a `SolverError` into a Python exception using the appropriate type.
|
||||
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)
|
||||
}
|
||||
_ => {
|
||||
crate::errors::SolverError::new_err(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NewtonConfig
|
||||
// =============================================================================
|
||||
|
||||
/// Configuration for the Newton-Raphson solver.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// config = NewtonConfig(max_iterations=100, tolerance=1e-6)
|
||||
/// result = config.solve(system)
|
||||
#[pyclass(name = "NewtonConfig", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyNewtonConfig {
|
||||
pub(crate) max_iterations: usize,
|
||||
pub(crate) tolerance: f64,
|
||||
pub(crate) line_search: bool,
|
||||
pub(crate) timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyNewtonConfig {
|
||||
#[new]
|
||||
#[pyo3(signature = (max_iterations=100, tolerance=1e-6, line_search=false, timeout_ms=None))]
|
||||
fn new(
|
||||
max_iterations: usize,
|
||||
tolerance: f64,
|
||||
line_search: bool,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Self {
|
||||
PyNewtonConfig {
|
||||
max_iterations,
|
||||
tolerance,
|
||||
line_search,
|
||||
timeout_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Solve the system. Returns a ConvergedState on success.
|
||||
///
|
||||
/// The GIL is released during solving so other Python threads can run.
|
||||
///
|
||||
/// Args:
|
||||
/// system: A finalized System.
|
||||
///
|
||||
/// Returns:
|
||||
/// ConvergedState: The solution.
|
||||
///
|
||||
/// Raises:
|
||||
/// SolverError: If the solver fails to converge.
|
||||
/// TimeoutError: If the solver times out.
|
||||
fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
|
||||
let mut config = entropyk_solver::NewtonConfig {
|
||||
max_iterations: self.max_iterations,
|
||||
tolerance: self.tolerance,
|
||||
line_search: self.line_search,
|
||||
timeout: self.timeout_ms.map(Duration::from_millis),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 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)
|
||||
}));
|
||||
|
||||
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.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"NewtonConfig(max_iter={}, tol={:.1e}, line_search={})",
|
||||
self.max_iterations, self.tolerance, self.line_search
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PicardConfig
|
||||
// =============================================================================
|
||||
|
||||
/// Configuration for the Picard (Sequential Substitution) solver.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// config = PicardConfig(max_iterations=500, tolerance=1e-4)
|
||||
/// result = config.solve(system)
|
||||
#[pyclass(name = "PicardConfig", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyPicardConfig {
|
||||
pub(crate) max_iterations: usize,
|
||||
pub(crate) tolerance: f64,
|
||||
pub(crate) relaxation: f64,
|
||||
}
|
||||
|
||||
#[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> {
|
||||
if !(0.0..=1.0).contains(&relaxation) {
|
||||
return Err(PyValueError::new_err(
|
||||
"relaxation must be between 0.0 and 1.0",
|
||||
));
|
||||
}
|
||||
Ok(PyPicardConfig {
|
||||
max_iterations,
|
||||
tolerance,
|
||||
relaxation,
|
||||
})
|
||||
}
|
||||
|
||||
/// Solve the system using Picard iteration. Returns a ConvergedState on success.
|
||||
///
|
||||
/// The GIL is released during solving so other Python threads can run.
|
||||
///
|
||||
/// Args:
|
||||
/// system: A finalized System.
|
||||
///
|
||||
/// Returns:
|
||||
/// ConvergedState: The solution.
|
||||
///
|
||||
/// Raises:
|
||||
/// SolverError: If the solver fails to converge.
|
||||
fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
|
||||
let mut config = entropyk_solver::PicardConfig {
|
||||
max_iterations: self.max_iterations,
|
||||
tolerance: self.tolerance,
|
||||
relaxation_factor: self.relaxation,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
use entropyk_solver::Solver;
|
||||
let solve_result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
|
||||
config.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.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"PicardConfig(max_iter={}, tol={:.1e}, relax={:.2})",
|
||||
self.max_iterations, self.tolerance, self.relaxation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FallbackConfig
|
||||
// =============================================================================
|
||||
|
||||
/// Configuration for the fallback solver (Newton → Picard).
|
||||
///
|
||||
/// Starts with Newton-Raphson and falls back to Picard on divergence.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// config = FallbackConfig()
|
||||
/// result = config.solve(system)
|
||||
#[pyclass(name = "FallbackConfig", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyFallbackConfig {
|
||||
pub(crate) newton: PyNewtonConfig,
|
||||
pub(crate) picard: PyPicardConfig,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFallbackConfig {
|
||||
#[new]
|
||||
#[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()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Solve the system using fallback strategy (Newton → Picard).
|
||||
///
|
||||
/// The GIL is released during solving so other Python threads can run.
|
||||
///
|
||||
/// Args:
|
||||
/// system: A finalized System.
|
||||
///
|
||||
/// Returns:
|
||||
/// ConvergedState: The solution.
|
||||
///
|
||||
/// Raises:
|
||||
/// SolverError: If both solvers fail to converge.
|
||||
fn solve(&self, system: &mut PySystem) -> PyResult<PyConvergedState> {
|
||||
let newton_config = entropyk_solver::NewtonConfig {
|
||||
max_iterations: self.newton.max_iterations,
|
||||
tolerance: self.newton.tolerance,
|
||||
line_search: self.newton.line_search,
|
||||
timeout: self.newton.timeout_ms.map(Duration::from_millis),
|
||||
..Default::default()
|
||||
};
|
||||
let picard_config = entropyk_solver::PicardConfig {
|
||||
max_iterations: self.picard.max_iterations,
|
||||
tolerance: self.picard.tolerance,
|
||||
relaxation_factor: self.picard.relaxation,
|
||||
..Default::default()
|
||||
};
|
||||
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(|| {
|
||||
fallback.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.",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"FallbackConfig(newton={}, picard={})",
|
||||
self.newton.__repr__(),
|
||||
self.picard.__repr__()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ConvergenceStatus
|
||||
// =============================================================================
|
||||
|
||||
/// Convergence status of a completed solve.
|
||||
#[pyclass(name = "ConvergenceStatus", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyConvergenceStatus {
|
||||
inner: entropyk_solver::ConvergenceStatus,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyConvergenceStatus {
|
||||
/// Whether the solver fully converged.
|
||||
#[getter]
|
||||
fn converged(&self) -> bool {
|
||||
matches!(
|
||||
self.inner,
|
||||
entropyk_solver::ConvergenceStatus::Converged
|
||||
| entropyk_solver::ConvergenceStatus::ControlSaturation
|
||||
)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{:?}", self.inner)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
match &self.inner {
|
||||
entropyk_solver::ConvergenceStatus::Converged => "Converged".to_string(),
|
||||
entropyk_solver::ConvergenceStatus::TimedOutWithBestState => "TimedOut".to_string(),
|
||||
entropyk_solver::ConvergenceStatus::ControlSaturation => "ControlSaturation".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &str) -> bool {
|
||||
match other {
|
||||
"Converged" => matches!(self.inner, entropyk_solver::ConvergenceStatus::Converged),
|
||||
"TimedOut" => matches!(
|
||||
self.inner,
|
||||
entropyk_solver::ConvergenceStatus::TimedOutWithBestState
|
||||
),
|
||||
"ControlSaturation" => {
|
||||
matches!(self.inner, entropyk_solver::ConvergenceStatus::ControlSaturation)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ConvergedState
|
||||
// =============================================================================
|
||||
|
||||
/// Result of a solved system.
|
||||
///
|
||||
/// Attributes:
|
||||
/// state_vector (list[float]): Final state vector [P0, h0, P1, h1, ...].
|
||||
/// iterations (int): Number of solver iterations.
|
||||
/// final_residual (float): L2 norm of the final residual.
|
||||
/// status (ConvergenceStatus): Convergence status.
|
||||
/// is_converged (bool): True if fully converged.
|
||||
#[pyclass(name = "ConvergedState", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyConvergedState {
|
||||
state: Vec<f64>,
|
||||
iterations: usize,
|
||||
final_residual: f64,
|
||||
status: entropyk_solver::ConvergenceStatus,
|
||||
}
|
||||
|
||||
impl PyConvergedState {
|
||||
pub(crate) fn from_rust(cs: entropyk_solver::ConvergedState) -> Self {
|
||||
PyConvergedState {
|
||||
state: cs.state,
|
||||
iterations: cs.iterations,
|
||||
final_residual: cs.final_residual,
|
||||
status: cs.status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyConvergedState {
|
||||
/// Final state vector as a Python list of floats.
|
||||
#[getter]
|
||||
fn state_vector(&self) -> Vec<f64> {
|
||||
self.state.clone()
|
||||
}
|
||||
|
||||
/// Number of iterations performed.
|
||||
#[getter]
|
||||
fn iterations(&self) -> usize {
|
||||
self.iterations
|
||||
}
|
||||
|
||||
/// L2 norm of the final residual vector.
|
||||
#[getter]
|
||||
fn final_residual(&self) -> f64 {
|
||||
self.final_residual
|
||||
}
|
||||
|
||||
/// Convergence status.
|
||||
#[getter]
|
||||
fn status(&self) -> PyConvergenceStatus {
|
||||
PyConvergenceStatus {
|
||||
inner: self.status.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the solver fully converged.
|
||||
#[getter]
|
||||
fn is_converged(&self) -> bool {
|
||||
matches!(
|
||||
self.status,
|
||||
entropyk_solver::ConvergenceStatus::Converged
|
||||
| entropyk_solver::ConvergenceStatus::ControlSaturation
|
||||
)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"ConvergedState(status={:?}, iterations={}, residual={:.2e})",
|
||||
self.status, self.iterations, self.final_residual
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the state vector as a NumPy array (zero-copy when possible).
|
||||
///
|
||||
/// Returns:
|
||||
/// numpy.ndarray: 1-D float64 array of state values.
|
||||
fn to_numpy<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, numpy::PyArray1<f64>>> {
|
||||
Ok(numpy::PyArray1::from_vec(py, self.state.clone()))
|
||||
}
|
||||
}
|
||||
341
bindings/python/src/types.rs
Normal file
341
bindings/python/src/types.rs
Normal file
@@ -0,0 +1,341 @@
|
||||
//! Python wrappers for Entropyk core physical types.
|
||||
//!
|
||||
//! Each wrapper holds the inner Rust NewType and exposes Pythonic constructors
|
||||
//! with keyword arguments (e.g., `Pressure(bar=1.0)`) plus unit conversion methods.
|
||||
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
// =============================================================================
|
||||
// Pressure
|
||||
// =============================================================================
|
||||
|
||||
/// Pressure in Pascals (Pa).
|
||||
///
|
||||
/// Construct with one of: ``pa``, ``bar``, ``kpa``, ``psi``.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// p = Pressure(bar=1.0)
|
||||
/// print(p.to_bar()) # 1.0
|
||||
/// print(float(p)) # 100000.0
|
||||
#[pyclass(name = "Pressure", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyPressure {
|
||||
pub(crate) inner: entropyk::Pressure,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
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> {
|
||||
let value = match (pa, bar, kpa, psi) {
|
||||
(Some(v), None, None, None) => v,
|
||||
(None, Some(v), None, None) => v * 100_000.0,
|
||||
(None, None, Some(v), None) => v * 1_000.0,
|
||||
(None, None, None, Some(v)) => v * 6894.75729,
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"Specify exactly one of: pa, bar, kpa, psi",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyPressure {
|
||||
inner: entropyk::Pressure(value),
|
||||
})
|
||||
}
|
||||
|
||||
/// Value in Pascals.
|
||||
fn to_pascals(&self) -> f64 {
|
||||
self.inner.to_pascals()
|
||||
}
|
||||
|
||||
/// Value in bar.
|
||||
fn to_bar(&self) -> f64 {
|
||||
self.inner.to_bar()
|
||||
}
|
||||
|
||||
/// Value in kPa.
|
||||
fn to_kpa(&self) -> f64 {
|
||||
self.inner.0 / 1_000.0
|
||||
}
|
||||
|
||||
/// Value in PSI.
|
||||
fn to_psi(&self) -> f64 {
|
||||
self.inner.to_psi()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Pressure({:.2} Pa = {:.4} bar)",
|
||||
self.inner.0,
|
||||
self.inner.0 / 100_000.0
|
||||
)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:.2} Pa", self.inner.0)
|
||||
}
|
||||
|
||||
fn __float__(&self) -> f64 {
|
||||
self.inner.0
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyPressure) -> bool {
|
||||
(self.inner.0 - other.inner.0).abs() < 1e-10
|
||||
}
|
||||
|
||||
fn __add__(&self, other: &PyPressure) -> PyPressure {
|
||||
PyPressure {
|
||||
inner: self.inner + other.inner,
|
||||
}
|
||||
}
|
||||
|
||||
fn __sub__(&self, other: &PyPressure) -> PyPressure {
|
||||
PyPressure {
|
||||
inner: self.inner - other.inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Temperature
|
||||
// =============================================================================
|
||||
|
||||
/// Temperature in Kelvin (K).
|
||||
///
|
||||
/// Construct with one of: ``kelvin``, ``celsius``, ``fahrenheit``.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// t = Temperature(celsius=25.0)
|
||||
/// print(t.to_kelvin()) # 298.15
|
||||
/// print(t.to_celsius()) # 25.0
|
||||
#[pyclass(name = "Temperature", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyTemperature {
|
||||
pub(crate) inner: entropyk::Temperature,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyTemperature {
|
||||
/// Create a Temperature. Specify exactly one of: ``kelvin``, ``celsius``, ``fahrenheit``.
|
||||
#[new]
|
||||
#[pyo3(signature = (kelvin=None, celsius=None, fahrenheit=None))]
|
||||
fn new(kelvin: Option<f64>, celsius: Option<f64>, fahrenheit: Option<f64>) -> PyResult<Self> {
|
||||
let inner = match (kelvin, celsius, fahrenheit) {
|
||||
(Some(v), None, None) => entropyk::Temperature::from_kelvin(v),
|
||||
(None, Some(v), None) => entropyk::Temperature::from_celsius(v),
|
||||
(None, None, Some(v)) => entropyk::Temperature::from_fahrenheit(v),
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"Specify exactly one of: kelvin, celsius, fahrenheit",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyTemperature { inner })
|
||||
}
|
||||
|
||||
/// Value in Kelvin.
|
||||
fn to_kelvin(&self) -> f64 {
|
||||
self.inner.to_kelvin()
|
||||
}
|
||||
|
||||
/// Value in Celsius.
|
||||
fn to_celsius(&self) -> f64 {
|
||||
self.inner.to_celsius()
|
||||
}
|
||||
|
||||
/// Value in Fahrenheit.
|
||||
fn to_fahrenheit(&self) -> f64 {
|
||||
self.inner.to_fahrenheit()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Temperature({:.2} K = {:.2} °C)",
|
||||
self.inner.0,
|
||||
self.inner.0 - 273.15
|
||||
)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:.2} K", self.inner.0)
|
||||
}
|
||||
|
||||
fn __float__(&self) -> f64 {
|
||||
self.inner.0
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyTemperature) -> bool {
|
||||
(self.inner.0 - other.inner.0).abs() < 1e-10
|
||||
}
|
||||
|
||||
fn __add__(&self, other: &PyTemperature) -> PyTemperature {
|
||||
PyTemperature {
|
||||
inner: entropyk::Temperature(self.inner.0 + other.inner.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn __sub__(&self, other: &PyTemperature) -> PyTemperature {
|
||||
PyTemperature {
|
||||
inner: entropyk::Temperature(self.inner.0 - other.inner.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Enthalpy
|
||||
// =============================================================================
|
||||
|
||||
/// Specific enthalpy in J/kg.
|
||||
///
|
||||
/// Construct with one of: ``j_per_kg``, ``kj_per_kg``.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// h = Enthalpy(kj_per_kg=250.0)
|
||||
/// print(h.to_kj_per_kg()) # 250.0
|
||||
#[pyclass(name = "Enthalpy", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyEnthalpy {
|
||||
pub(crate) inner: entropyk::Enthalpy,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyEnthalpy {
|
||||
/// Create an Enthalpy. Specify exactly one of: ``j_per_kg``, ``kj_per_kg``.
|
||||
#[new]
|
||||
#[pyo3(signature = (j_per_kg=None, kj_per_kg=None))]
|
||||
fn new(j_per_kg: Option<f64>, kj_per_kg: Option<f64>) -> PyResult<Self> {
|
||||
let inner = match (j_per_kg, kj_per_kg) {
|
||||
(Some(v), None) => entropyk::Enthalpy::from_joules_per_kg(v),
|
||||
(None, Some(v)) => entropyk::Enthalpy::from_kilojoules_per_kg(v),
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"Specify exactly one of: j_per_kg, kj_per_kg",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyEnthalpy { inner })
|
||||
}
|
||||
|
||||
/// Value in J/kg.
|
||||
fn to_j_per_kg(&self) -> f64 {
|
||||
self.inner.to_joules_per_kg()
|
||||
}
|
||||
|
||||
/// Value in kJ/kg.
|
||||
fn to_kj_per_kg(&self) -> f64 {
|
||||
self.inner.to_kilojoules_per_kg()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"Enthalpy({:.2} J/kg = {:.2} kJ/kg)",
|
||||
self.inner.0,
|
||||
self.inner.0 / 1_000.0
|
||||
)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:.2} J/kg", self.inner.0)
|
||||
}
|
||||
|
||||
fn __float__(&self) -> f64 {
|
||||
self.inner.0
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyEnthalpy) -> bool {
|
||||
(self.inner.0 - other.inner.0).abs() < 1e-10
|
||||
}
|
||||
|
||||
fn __add__(&self, other: &PyEnthalpy) -> PyEnthalpy {
|
||||
PyEnthalpy {
|
||||
inner: entropyk::Enthalpy(self.inner.0 + other.inner.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn __sub__(&self, other: &PyEnthalpy) -> PyEnthalpy {
|
||||
PyEnthalpy {
|
||||
inner: entropyk::Enthalpy(self.inner.0 - other.inner.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MassFlow
|
||||
// =============================================================================
|
||||
|
||||
/// Mass flow rate in kg/s.
|
||||
///
|
||||
/// Construct with one of: ``kg_per_s``, ``g_per_s``.
|
||||
///
|
||||
/// Example::
|
||||
///
|
||||
/// m = MassFlow(kg_per_s=0.5)
|
||||
/// print(m.to_g_per_s()) # 500.0
|
||||
#[pyclass(name = "MassFlow", module = "entropyk")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyMassFlow {
|
||||
pub(crate) inner: entropyk::MassFlow,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyMassFlow {
|
||||
/// Create a MassFlow. Specify exactly one of: ``kg_per_s``, ``g_per_s``.
|
||||
#[new]
|
||||
#[pyo3(signature = (kg_per_s=None, g_per_s=None))]
|
||||
fn new(kg_per_s: Option<f64>, g_per_s: Option<f64>) -> PyResult<Self> {
|
||||
let inner = match (kg_per_s, g_per_s) {
|
||||
(Some(v), None) => entropyk::MassFlow::from_kg_per_s(v),
|
||||
(None, Some(v)) => entropyk::MassFlow::from_grams_per_s(v),
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"Specify exactly one of: kg_per_s, g_per_s",
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(PyMassFlow { inner })
|
||||
}
|
||||
|
||||
/// Value in kg/s.
|
||||
fn to_kg_per_s(&self) -> f64 {
|
||||
self.inner.to_kg_per_s()
|
||||
}
|
||||
|
||||
/// Value in g/s.
|
||||
fn to_g_per_s(&self) -> f64 {
|
||||
self.inner.to_grams_per_s()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("MassFlow({:.6} kg/s)", self.inner.0)
|
||||
}
|
||||
|
||||
fn __str__(&self) -> String {
|
||||
format!("{:.6} kg/s", self.inner.0)
|
||||
}
|
||||
|
||||
fn __float__(&self) -> f64 {
|
||||
self.inner.0
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyMassFlow) -> bool {
|
||||
(self.inner.0 - other.inner.0).abs() < 1e-15
|
||||
}
|
||||
|
||||
fn __add__(&self, other: &PyMassFlow) -> PyMassFlow {
|
||||
PyMassFlow {
|
||||
inner: entropyk::MassFlow(self.inner.0 + other.inner.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn __sub__(&self, other: &PyMassFlow) -> PyMassFlow {
|
||||
PyMassFlow {
|
||||
inner: entropyk::MassFlow(self.inner.0 - other.inner.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user