feat(python): implement python bindings for all components and solvers

This commit is contained in:
Sepehr
2026-02-21 20:34:56 +01:00
parent 8ef8cd2eba
commit 4440132b0a
310 changed files with 11577 additions and 397 deletions

View File

@@ -0,0 +1,781 @@
//! Python wrappers for Entropyk thermodynamic components.
//!
//! Components are wrapped with simplified Pythonic constructors.
//! Type-statebased components (Compressor, ExpansionValve, Pipe) use
//! `SimpleAdapter` wrappers that bridge between Python construction and
//! the Rust system's `Component` trait. These adapters store config and
//! produce correct equation counts for the solver graph.
//!
//! Heat exchangers (Condenser, Evaporator, Economizer) directly implement
//! `Component` so they use the real Rust types.
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 (01).
#[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 (01), 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(),
}
}
}

View 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()),
}
}

View 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(())
}

View 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()))
}
}

View 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),
}
}
}