feat: implement mass balance validation for Story 7.1

- Added port_mass_flows to Component trait and implements for core components.
- Added System::check_mass_balance and integrated it into the solver.
- Restored connect methods for ExpansionValve, Compressor, and Pipe to fix integration tests.
- Updated Python and C bindings for validation errors.
- Updated sprint status and story documentation.
This commit is contained in:
Sepehr
2026-02-21 23:21:34 +01:00
parent 4440132b0a
commit fa480ed303
55 changed files with 5987 additions and 31 deletions

View File

@@ -91,6 +91,7 @@ impl std::fmt::Debug for SimpleAdapter {
/// )
#[pyclass(name = "Compressor", module = "entropyk")]
#[derive(Clone)]
#[allow(dead_code)] // Fields reserved until SimpleAdapter type-state migration
pub struct PyCompressor {
pub(crate) coefficients: entropyk::Ahri540Coefficients,
pub(crate) speed_rpm: f64,
@@ -381,6 +382,7 @@ impl PyExpansionValve {
/// density=1140.0, viscosity=0.0002)
#[pyclass(name = "Pipe", module = "entropyk")]
#[derive(Clone)]
#[allow(dead_code)] // Fields reserved until SimpleAdapter type-state migration
pub struct PyPipe {
pub(crate) length: f64,
pub(crate) diameter: f64,

View File

@@ -37,6 +37,7 @@ pub fn register_exceptions(m: &Bound<'_, PyModule>) -> PyResult<()> {
}
/// Converts a `ThermoError` into the appropriate Python exception.
#[allow(dead_code)]
pub fn thermo_error_to_pyerr(err: entropyk::ThermoError) -> PyErr {
use entropyk::ThermoError;
match &err {
@@ -48,6 +49,8 @@ pub fn thermo_error_to_pyerr(err: entropyk::ThermoError) -> PyErr {
TimeoutError::new_err(msg)
} else if solver_msg.contains("saturation") || solver_msg.contains("Saturation") {
ControlSaturationError::new_err(msg)
} else if solver_msg.contains("validation") || solver_msg.contains("Validation") {
ValidationError::new_err(msg)
} else {
SolverError::new_err(msg)
}
@@ -67,6 +70,7 @@ pub fn thermo_error_to_pyerr(err: entropyk::ThermoError) -> PyErr {
| ThermoError::Mixture(_)
| ThermoError::InvalidInput(_)
| ThermoError::NotSupported(_)
| ThermoError::NotFinalized => EntropykError::new_err(err.to_string()),
| ThermoError::NotFinalized
| ThermoError::Validation { .. } => EntropykError::new_err(err.to_string()),
}
}

View File

@@ -44,6 +44,8 @@ fn entropyk(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<solver::PyFallbackConfig>()?;
m.add_class::<solver::PyConvergedState>()?;
m.add_class::<solver::PyConvergenceStatus>()?;
m.add_class::<solver::PyConstraint>()?;
m.add_class::<solver::PyBoundedVariable>()?;
Ok(())
}

View File

@@ -1,5 +1,3 @@
//! Python wrappers for Entropyk solver and system types.
use pyo3::prelude::*;
use pyo3::exceptions::{PyValueError, PyRuntimeError};
use std::time::Duration;
@@ -25,7 +23,90 @@ use crate::components::AnyPyComponent;
/// system.finalize()
#[pyclass(name = "System", module = "entropyk", unsendable)]
pub struct PySystem {
inner: entropyk_solver::System,
pub(crate) inner: entropyk_solver::System,
}
#[pyclass(name = "Constraint")]
#[derive(Clone)]
pub struct PyConstraint {
pub(crate) inner: entropyk_solver::inverse::Constraint,
}
#[pymethods]
impl PyConstraint {
#[staticmethod]
#[pyo3(signature = (id, component_id, target_value, tolerance=1e-6))]
fn superheat(id: String, component_id: String, target_value: f64, tolerance: f64) -> Self {
use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
Self {
inner: Constraint::with_tolerance(
ConstraintId::new(id),
ComponentOutput::Superheat { component_id },
target_value,
tolerance,
).unwrap(),
}
}
#[staticmethod]
#[pyo3(signature = (id, component_id, target_value, tolerance=1e-6))]
fn subcooling(id: String, component_id: String, target_value: f64, tolerance: f64) -> Self {
use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
Self {
inner: Constraint::with_tolerance(
ConstraintId::new(id),
ComponentOutput::Subcooling { component_id },
target_value,
tolerance,
).unwrap(),
}
}
#[staticmethod]
#[pyo3(signature = (id, component_id, target_value, tolerance=1e-6))]
fn capacity(id: String, component_id: String, target_value: f64, tolerance: f64) -> Self {
use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
Self {
inner: Constraint::with_tolerance(
ConstraintId::new(id),
ComponentOutput::Capacity { component_id },
target_value,
tolerance,
).unwrap(),
}
}
fn __repr__(&self) -> String {
format!("Constraint(id='{}', target={}, tol={})", self.inner.id(), self.inner.target_value(), self.inner.tolerance())
}
}
#[pyclass(name = "BoundedVariable")]
#[derive(Clone)]
pub struct PyBoundedVariable {
pub(crate) inner: entropyk_solver::inverse::BoundedVariable,
}
#[pymethods]
impl PyBoundedVariable {
#[new]
#[pyo3(signature = (id, value, min, max, component_id=None))]
fn new(id: String, value: f64, min: f64, max: f64, component_id: Option<String>) -> PyResult<Self> {
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
let inner = match component_id {
Some(cid) => BoundedVariable::with_component(BoundedVariableId::new(id), cid, value, min, max),
None => BoundedVariable::new(BoundedVariableId::new(id), value, min, max),
};
match inner {
Ok(v) => Ok(Self { inner: v }),
Err(e) => Err(PyValueError::new_err(e.to_string())),
}
}
fn __repr__(&self) -> String {
// use is_saturated if available but simpler:
format!("BoundedVariable(id='{}', value={}, bounds=[{}, {}])", self.inner.id(), self.inner.value(), self.inner.min(), self.inner.max())
}
}
#[pymethods]
@@ -69,6 +150,34 @@ impl PySystem {
Ok(edge.index())
}
/// Register a human-readable name for a component node to be used in Constraints.
fn register_component_name(&mut self, name: &str, node_idx: usize) -> PyResult<()> {
let node = petgraph::graph::NodeIndex::new(node_idx);
self.inner.register_component_name(name, node);
Ok(())
}
/// Add a constraint to the system.
fn add_constraint(&mut self, constraint: &PyConstraint) -> PyResult<()> {
self.inner.add_constraint(constraint.inner.clone())
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Add a bounded variable to the system.
fn add_bounded_variable(&mut self, variable: &PyBoundedVariable) -> PyResult<()> {
self.inner.add_bounded_variable(variable.inner.clone())
.map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Link a constraint to a control variable for the inverse solver.
fn link_constraint_to_control(&mut self, constraint_id: &str, control_id: &str) -> PyResult<()> {
use entropyk_solver::inverse::{ConstraintId, BoundedVariableId};
self.inner.link_constraint_to_control(
&ConstraintId::new(constraint_id),
&BoundedVariableId::new(control_id),
).map_err(|e| PyValueError::new_err(e.to_string()))
}
/// Finalize the system graph: build state index mapping and validate topology.
///
/// Must be called before ``solve()``.