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

@@ -0,0 +1,72 @@
//! WASM-specific backend initialization.
//!
//! Provides TabularBackend with embedded fluid tables for WASM builds.
//! CoolProp C++ cannot compile to WASM, so we must use tabular interpolation.
use entropyk_fluids::{FluidBackend, TabularBackend};
use wasm_bindgen::prelude::*;
/// Embedded R134a fluid table data.
const R134A_TABLE: &str = include_str!("../../../crates/fluids/data/r134a.json");
/// Create the default backend for WASM with embedded fluid tables.
pub fn create_default_backend() -> TabularBackend {
let mut backend = TabularBackend::new();
backend
.load_table_from_str(R134A_TABLE)
.expect("Embedded R134a table must be valid");
backend
}
/// Create an empty backend for custom fluid loading.
pub fn create_empty_backend() -> TabularBackend {
TabularBackend::new()
}
/// Load a fluid table from a JSON string (exposed to JS).
#[wasm_bindgen]
pub fn load_fluid_table(json: String) -> Result<(), String> {
let mut backend = create_empty_backend();
backend
.load_table_from_str(&json)
.map_err(|e| format!("Failed to load fluid table: {}", e))?;
Ok(())
}
/// Get list of available fluids in the default backend.
#[wasm_bindgen]
pub fn list_available_fluids() -> Vec<String> {
let backend = create_default_backend();
backend.list_fluids().into_iter().map(|f| f.0).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::*;
#[wasm_bindgen_test]
fn test_create_default_backend() {
let backend = create_default_backend();
let fluids = backend.list_fluids();
assert!(!fluids.is_empty());
assert!(fluids.iter().any(|f| f.0 == "R134a"));
}
#[wasm_bindgen_test]
fn test_list_available_fluids() {
let fluids = list_available_fluids();
assert!(!fluids.is_empty());
assert!(fluids.contains(&"R134a".to_string()));
}
#[wasm_bindgen_test]
fn test_create_empty_backend() {
let backend = create_empty_backend();
let fluids = backend.list_fluids();
assert!(fluids.is_empty());
}
}

View File

@@ -0,0 +1,150 @@
//! WASM component bindings (stub).
//!
//! Provides JavaScript-friendly wrappers for thermodynamic components.
//! NOTE: This is a minimal implementation to demonstrate the WASM build.
//! Full component bindings require additional development.
use crate::types::{WasmEnthalpy, WasmMassFlow, WasmPressure, WasmTemperature};
use serde::Serialize;
use wasm_bindgen::prelude::*;
/// WASM wrapper for Compressor component (stub).
#[wasm_bindgen]
pub struct WasmCompressor {
_fluid: String,
}
#[wasm_bindgen]
impl WasmCompressor {
/// Create a new compressor.
#[wasm_bindgen(constructor)]
pub fn new(fluid: String) -> Result<WasmCompressor, JsValue> {
Ok(WasmCompressor { _fluid: fluid })
}
/// Get component name.
pub fn name(&self) -> String {
"Compressor".to_string()
}
}
/// WASM wrapper for Condenser component (stub).
#[wasm_bindgen]
pub struct WasmCondenser {
_fluid: String,
_ua: f64,
}
#[wasm_bindgen]
impl WasmCondenser {
/// Create a new condenser.
#[wasm_bindgen(constructor)]
pub fn new(fluid: String, ua: f64) -> Result<WasmCondenser, JsValue> {
Ok(WasmCondenser {
_fluid: fluid,
_ua: ua,
})
}
/// Get component name.
pub fn name(&self) -> String {
"Condenser".to_string()
}
}
/// WASM wrapper for Evaporator component (stub).
#[wasm_bindgen]
pub struct WasmEvaporator {
_fluid: String,
_ua: f64,
}
#[wasm_bindgen]
impl WasmEvaporator {
/// Create a new evaporator.
#[wasm_bindgen(constructor)]
pub fn new(fluid: String, ua: f64) -> Result<WasmEvaporator, JsValue> {
Ok(WasmEvaporator {
_fluid: fluid,
_ua: ua,
})
}
/// Get component name.
pub fn name(&self) -> String {
"Evaporator".to_string()
}
}
/// WASM wrapper for ExpansionValve component (stub).
#[wasm_bindgen]
pub struct WasmExpansionValve {
_fluid: String,
}
#[wasm_bindgen]
impl WasmExpansionValve {
/// Create a new expansion valve.
#[wasm_bindgen(constructor)]
pub fn new(fluid: String) -> Result<WasmExpansionValve, JsValue> {
Ok(WasmExpansionValve { _fluid: fluid })
}
/// Get component name.
pub fn name(&self) -> String {
"ExpansionValve".to_string()
}
}
/// WASM wrapper for Economizer component (stub).
#[wasm_bindgen]
pub struct WasmEconomizer {
_fluid: String,
_ua: f64,
}
#[wasm_bindgen]
impl WasmEconomizer {
/// Create a new economizer.
#[wasm_bindgen(constructor)]
pub fn new(fluid: String, ua: f64) -> Result<WasmEconomizer, JsValue> {
Ok(WasmEconomizer {
_fluid: fluid,
_ua: ua,
})
}
/// Get component name.
pub fn name(&self) -> String {
"Economizer".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[wasm_bindgen_test]
fn test_compressor_creation() {
let compressor = WasmCompressor::new("R134a".to_string());
assert!(compressor.is_ok());
}
#[wasm_bindgen_test]
fn test_condenser_creation() {
let condenser = WasmCondenser::new("R134a".to_string(), 1000.0);
assert!(condenser.is_ok());
}
#[wasm_bindgen_test]
fn test_evaporator_creation() {
let evaporator = WasmEvaporator::new("R134a".to_string(), 800.0);
assert!(evaporator.is_ok());
}
#[wasm_bindgen_test]
fn test_expansion_valve_creation() {
let valve = WasmExpansionValve::new("R134a".to_string());
assert!(valve.is_ok());
}
}

View File

@@ -0,0 +1,10 @@
//! Error handling for WASM bindings.
//!
//! Maps errors to JavaScript exceptions with human-readable messages.
use wasm_bindgen::JsValue;
/// Convert a Result to a Result with JsValue error.
pub fn result_to_js<T, E: std::fmt::Display>(result: Result<T, E>) -> Result<T, JsValue> {
result.map_err(|e| js_sys::Error::new(&e.to_string()).into())
}

24
bindings/wasm/src/lib.rs Normal file
View File

@@ -0,0 +1,24 @@
//! Entropyk WebAssembly bindings.
//!
//! This crate provides WebAssembly wrappers for the Entropyk thermodynamic
//! simulation library via wasm-bindgen.
use wasm_bindgen::prelude::*;
pub(crate) mod backend;
pub(crate) mod components;
pub(crate) mod errors;
pub(crate) mod solver;
pub(crate) mod types;
/// Initialize the WASM module.
#[wasm_bindgen]
pub fn init() {
console_error_panic_hook::set_once();
}
/// Get the library version.
#[wasm_bindgen]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}

260
bindings/wasm/src/solver.rs Normal file
View File

@@ -0,0 +1,260 @@
//! WASM solver bindings.
//!
//! Provides JavaScript-friendly wrappers for the solver and system.
use crate::backend::create_default_backend;
use entropyk_solver::{
ConvergedState, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
SolverStrategy, System,
};
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
/// WASM wrapper for Newton-Raphson solver configuration.
#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct WasmNewtonConfig {
pub(crate) inner: NewtonConfig,
}
#[wasm_bindgen]
impl WasmNewtonConfig {
/// Create default Newton-Raphson configuration.
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
WasmNewtonConfig {
inner: NewtonConfig::default(),
}
}
/// Set maximum iterations.
pub fn set_max_iterations(&mut self, max: usize) {
self.inner.max_iterations = max;
}
/// Set convergence tolerance.
pub fn set_tolerance(&mut self, tol: f64) {
self.inner.tolerance = tol;
}
}
impl Default for WasmNewtonConfig {
fn default() -> Self {
Self::new()
}
}
/// WASM wrapper for Picard (Sequential Substitution) solver configuration.
#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct WasmPicardConfig {
pub(crate) inner: PicardConfig,
}
#[wasm_bindgen]
impl WasmPicardConfig {
/// Create default Picard configuration.
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
WasmPicardConfig {
inner: PicardConfig::default(),
}
}
/// Set maximum iterations.
pub fn set_max_iterations(&mut self, max: usize) {
self.inner.max_iterations = max;
}
/// Set relaxation factor.
pub fn set_relaxation_factor(&mut self, omega: f64) {
self.inner.relaxation_factor = omega;
}
}
impl Default for WasmPicardConfig {
fn default() -> Self {
Self::new()
}
}
/// WASM wrapper for fallback solver configuration.
#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct WasmFallbackConfig {
newton_config: NewtonConfig,
picard_config: PicardConfig,
}
#[wasm_bindgen]
impl WasmFallbackConfig {
/// Create default fallback configuration.
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
WasmFallbackConfig {
newton_config: NewtonConfig::default(),
picard_config: PicardConfig::default(),
}
}
}
impl Default for WasmFallbackConfig {
fn default() -> Self {
Self::new()
}
}
/// WASM wrapper for converged state (solver result).
#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct WasmConvergedState {
/// Convergence status
pub converged: bool,
/// Number of iterations
pub iterations: usize,
/// Final residual
pub final_residual: f64,
}
#[wasm_bindgen]
impl WasmConvergedState {
/// Convert to JSON string.
pub fn toJson(&self) -> String {
format!(
r#"{{"converged":{},"iterations":{},"final_residual":{}}}"#,
self.converged, self.iterations, self.final_residual
)
}
}
impl From<&ConvergedState> for WasmConvergedState {
fn from(state: &ConvergedState) -> Self {
WasmConvergedState {
converged: state.is_converged(),
iterations: state.iterations,
final_residual: state.final_residual,
}
}
}
/// WASM wrapper for System (thermodynamic system).
#[wasm_bindgen]
pub struct WasmSystem {
inner: Rc<RefCell<System>>,
}
#[wasm_bindgen]
impl WasmSystem {
/// Create a new thermodynamic system.
#[wasm_bindgen(constructor)]
pub fn new() -> Result<WasmSystem, JsValue> {
let system = System::new();
Ok(WasmSystem {
inner: Rc::new(RefCell::new(system)),
})
}
/// Solve the system with fallback strategy.
pub fn solve(&mut self, _config: WasmFallbackConfig) -> Result<WasmConvergedState, JsValue> {
let mut solver = FallbackSolver::default();
let state = solver
.solve(&mut self.inner.borrow_mut())
.map_err(|e: entropyk_solver::SolverError| js_sys::Error::new(&e.to_string()))?;
Ok((&state).into())
}
/// Solve with Newton-Raphson method.
pub fn solve_newton(
&mut self,
config: WasmNewtonConfig,
) -> Result<WasmConvergedState, JsValue> {
let mut solver = SolverStrategy::NewtonRaphson(config.inner);
let state = solver
.solve(&mut self.inner.borrow_mut())
.map_err(|e: entropyk_solver::SolverError| js_sys::Error::new(&e.to_string()))?;
Ok((&state).into())
}
/// Solve with Picard (Sequential Substitution) method.
pub fn solve_picard(
&mut self,
config: WasmPicardConfig,
) -> Result<WasmConvergedState, JsValue> {
let mut solver = SolverStrategy::SequentialSubstitution(config.inner);
let state = solver
.solve(&mut self.inner.borrow_mut())
.map_err(|e: entropyk_solver::SolverError| js_sys::Error::new(&e.to_string()))?;
Ok((&state).into())
}
/// Solve with Picard (Sequential Substitution) method.
pub fn solve_picard(
&mut self,
config: WasmPicardConfig,
) -> Result<WasmConvergedState, JsValue> {
let mut solver = config.inner;
let state = solver
.solve(&mut self.inner.borrow_mut())
.map_err(|e| js_sys::Error::new(&e.to_string()))?;
Ok((&state).into())
}
/// Get node count.
pub fn node_count(&self) -> usize {
self.inner.borrow().node_count()
}
/// Get edge count.
pub fn edge_count(&self) -> usize {
self.inner.borrow().edge_count()
}
/// Convert system state to JSON.
pub fn toJson(&self) -> String {
format!(
r#"{{"node_count":{},"edge_count":{}}}"#,
self.node_count(),
self.edge_count()
)
}
}
impl Default for WasmSystem {
fn default() -> Self {
Self::new().expect("Failed to create default system")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[wasm_bindgen_test]
fn test_newton_config_creation() {
let config = WasmNewtonConfig::new();
assert!(config.inner.max_iterations > 0);
}
#[wasm_bindgen_test]
fn test_picard_config_creation() {
let config = WasmPicardConfig::new();
assert!(config.inner.max_iterations > 0);
}
#[wasm_bindgen_test]
fn test_fallback_config_creation() {
let config = WasmFallbackConfig::new();
assert!(config.newton_config.max_iterations > 0);
}
#[wasm_bindgen_test]
fn test_system_creation() {
let system = WasmSystem::new();
assert!(system.is_ok());
}
}

243
bindings/wasm/src/types.rs Normal file
View File

@@ -0,0 +1,243 @@
//! WASM type wrappers for core physical types.
//!
//! Provides JavaScript-friendly wrappers for Pressure, Temperature,
//! Enthalpy, and MassFlow with JSON serialization support.
use entropyk_core::{Enthalpy, MassFlow, Pressure, Temperature};
use wasm_bindgen::prelude::*;
/// Pressure in Pascals.
#[wasm_bindgen]
#[derive(Clone, Copy, Debug)]
pub struct WasmPressure {
pascals: f64,
}
#[wasm_bindgen]
impl WasmPressure {
/// Create pressure from Pascals.
#[wasm_bindgen(constructor)]
pub fn new(pascals: f64) -> Self {
WasmPressure { pascals }
}
/// Create pressure from bar.
pub fn from_bar(bar: f64) -> Self {
WasmPressure {
pascals: bar * 100_000.0,
}
}
/// Get pressure in Pascals.
pub fn pascals(&self) -> f64 {
self.pascals
}
/// Get pressure in bar.
pub fn bar(&self) -> f64 {
self.pascals / 100_000.0
}
/// Convert to JSON string.
pub fn toJson(&self) -> String {
format!(r#"{{"pascals":{},"bar":{}}}"#, self.pascals, self.bar())
}
}
impl From<Pressure> for WasmPressure {
fn from(p: Pressure) -> Self {
WasmPressure {
pascals: p.to_pascals(),
}
}
}
impl From<WasmPressure> for Pressure {
fn from(p: WasmPressure) -> Self {
Pressure::from_pascals(p.pascals)
}
}
/// Temperature in Kelvin.
#[wasm_bindgen]
#[derive(Clone, Copy, Debug)]
pub struct WasmTemperature {
kelvin: f64,
}
#[wasm_bindgen]
impl WasmTemperature {
/// Create temperature from Kelvin.
#[wasm_bindgen(constructor)]
pub fn new(kelvin: f64) -> Self {
WasmTemperature { kelvin }
}
/// Create temperature from Celsius.
pub fn from_celsius(celsius: f64) -> Self {
WasmTemperature {
kelvin: celsius + 273.15,
}
}
/// Get temperature in Kelvin.
pub fn kelvin(&self) -> f64 {
self.kelvin
}
/// Get temperature in Celsius.
pub fn celsius(&self) -> f64 {
self.kelvin - 273.15
}
/// Convert to JSON string.
pub fn toJson(&self) -> String {
format!(
r#"{{"kelvin":{},"celsius":{}}}"#,
self.kelvin,
self.celsius()
)
}
}
impl From<Temperature> for WasmTemperature {
fn from(t: Temperature) -> Self {
WasmTemperature {
kelvin: t.to_kelvin(),
}
}
}
impl From<WasmTemperature> for Temperature {
fn from(t: WasmTemperature) -> Self {
Temperature::from_kelvin(t.kelvin)
}
}
/// Enthalpy in J/kg.
#[wasm_bindgen]
#[derive(Clone, Copy, Debug)]
pub struct WasmEnthalpy {
joules_per_kg: f64,
}
#[wasm_bindgen]
impl WasmEnthalpy {
/// Create enthalpy from J/kg.
#[wasm_bindgen(constructor)]
pub fn new(joules_per_kg: f64) -> Self {
WasmEnthalpy { joules_per_kg }
}
/// Create enthalpy from kJ/kg.
pub fn from_kj_per_kg(kj_per_kg: f64) -> Self {
WasmEnthalpy {
joules_per_kg: kj_per_kg * 1000.0,
}
}
/// Get enthalpy in J/kg.
pub fn joules_per_kg(&self) -> f64 {
self.joules_per_kg
}
/// Get enthalpy in kJ/kg.
pub fn kj_per_kg(&self) -> f64 {
self.joules_per_kg / 1000.0
}
/// Convert to JSON string.
pub fn toJson(&self) -> String {
format!(
r#"{{"joules_per_kg":{},"kj_per_kg":{}}}"#,
self.joules_per_kg,
self.kj_per_kg()
)
}
}
impl From<Enthalpy> for WasmEnthalpy {
fn from(h: Enthalpy) -> Self {
WasmEnthalpy {
joules_per_kg: h.to_joules_per_kg(),
}
}
}
impl From<WasmEnthalpy> for Enthalpy {
fn from(h: WasmEnthalpy) -> Self {
Enthalpy::from_joules_per_kg(h.joules_per_kg)
}
}
/// Mass flow in kg/s.
#[wasm_bindgen]
#[derive(Clone, Copy, Debug)]
pub struct WasmMassFlow {
kg_per_s: f64,
}
#[wasm_bindgen]
impl WasmMassFlow {
/// Create mass flow from kg/s.
#[wasm_bindgen(constructor)]
pub fn new(kg_per_s: f64) -> Self {
WasmMassFlow { kg_per_s }
}
/// Get mass flow in kg/s.
pub fn kg_per_s(&self) -> f64 {
self.kg_per_s
}
/// Convert to JSON string.
pub fn toJson(&self) -> String {
format!(r#"{{"kg_per_s":{}}}"#, self.kg_per_s)
}
}
impl From<MassFlow> for WasmMassFlow {
fn from(m: MassFlow) -> Self {
WasmMassFlow {
kg_per_s: m.to_kg_per_s(),
}
}
}
impl From<WasmMassFlow> for MassFlow {
fn from(m: WasmMassFlow) -> Self {
MassFlow::from_kg_per_s(m.kg_per_s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[wasm_bindgen_test]
fn test_pressure_creation() {
let p = WasmPressure::from_bar(1.0);
assert!((p.pascals() - 100000.0).abs() < 1e-6);
assert!((p.bar() - 1.0).abs() < 1e-9);
}
#[wasm_bindgen_test]
fn test_temperature_creation() {
let t = WasmTemperature::from_celsius(25.0);
assert!((t.kelvin() - 298.15).abs() < 1e-6);
assert!((t.celsius() - 25.0).abs() < 1e-9);
}
#[wasm_bindgen_test]
fn test_enthalpy_creation() {
let h = WasmEnthalpy::from_kj_per_kg(400.0);
assert!((h.joules_per_kg() - 400000.0).abs() < 1e-6);
assert!((h.kj_per_kg() - 400.0).abs() < 1e-9);
}
#[wasm_bindgen_test]
fn test_massflow_creation() {
let m = WasmMassFlow::new(0.1);
assert!((m.kg_per_s() - 0.1).abs() < 1e-9);
}
}