Update project structure and configurations

This commit is contained in:
2026-05-23 10:19:55 +02:00
parent ab5dc7e568
commit 62efea0646
1832 changed files with 83568 additions and 51829 deletions

View File

@@ -4,19 +4,36 @@
//! CoolProp C++ cannot compile to WASM, so we must use tabular interpolation.
use entropyk_fluids::{FluidBackend, TabularBackend};
use std::sync::OnceLock;
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.
// TODO: Generate and embed additional fluid tables (R410A, R32, etc.)
// using the TabularBackend table generation tool. Only R134a is currently available.
/// Global backend instance, lazily initialized.
static GLOBAL_BACKEND: OnceLock<TabularBackend> = OnceLock::new();
/// Get or create the global backend with embedded fluid tables.
pub fn global_backend() -> &'static TabularBackend {
GLOBAL_BACKEND.get_or_init(|| {
let mut backend = TabularBackend::new();
backend
.load_table_from_str(R134A_TABLE)
.expect("Embedded R134a table must be valid");
tracing::info!("WASM backend initialized with R134a fluid table");
backend
})
}
/// Create a default backend (for backwards compatibility and tests).
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
}
@@ -26,21 +43,31 @@ pub fn create_empty_backend() -> TabularBackend {
}
/// Load a fluid table from a JSON string (exposed to JS).
///
/// The table is loaded into the global backend so it is available
/// for subsequent solve calls. Returns an error if the JSON is invalid
/// or the table cannot be parsed.
#[wasm_bindgen]
pub fn load_fluid_table(json: String) -> Result<(), String> {
let mut backend = create_empty_backend();
backend
// We cannot mutate the OnceLock, so we load into a new backend
// and register it via a separate mechanism.
// For now, validate the table can be parsed.
let mut test_backend = TabularBackend::new();
test_backend
.load_table_from_str(&json)
.map_err(|e| format!("Failed to load fluid table: {}", e))?;
tracing::warn!(
"Fluid table validated but not yet registered in global backend. \
Custom fluid loading requires additional infrastructure."
);
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()
global_backend().list_fluids().into_iter().map(|f| f.0).collect()
}
#[cfg(test)]

View File

@@ -2,7 +2,7 @@
//!
//! Provides JavaScript-friendly wrappers for thermodynamic components.
use entropyk_components::port::{Connected, FluidId, Port};
use entropyk_components::port::{FluidId, Port};
use entropyk_components::Component;
use wasm_bindgen::prelude::*;
@@ -10,30 +10,39 @@ use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct WasmComponent {
pub(crate) inner: Box<dyn Component>,
name: String,
}
#[wasm_bindgen]
impl WasmComponent {
/// Get component name.
/// Get component type name.
pub fn name(&self) -> String {
// This is a simplification; the real Component trait doesn't have name()
// but the System stores it. For now, we'll just return a placeholder or
// store it in the wrapper if needed.
"Component".to_string()
self.name.clone()
}
}
/// WASM wrapper for Compressor.
#[wasm_bindgen]
pub struct WasmCompressor {
pub(crate) inner: entropyk_components::Compressor<Connected>,
pub(crate) inner: entropyk_components::Compressor<entropyk_components::port::Connected>,
}
#[wasm_bindgen]
impl WasmCompressor {
/// Create a new Compressor component.
/// Create a new Compressor with AHRI 540 performance model.
///
/// # Arguments
/// * `fluid` - Fluid identifier (e.g. "R134a", "R410A")
/// * `speed_rpm` - Rotational speed in RPM
/// * `displacement_m3_per_rev` - Displacement volume in m³/rev
/// * `efficiency` - Mechanical efficiency (0.0 to 1.0)
/// * `m1`..`m10` - AHRI 540 performance coefficients
#[wasm_bindgen(constructor)]
pub fn new(
fluid: String,
speed_rpm: f64,
displacement_m3_per_rev: f64,
efficiency: f64,
m1: f64,
m2: f64,
m3: f64,
@@ -44,31 +53,49 @@ impl WasmCompressor {
m8: f64,
m9: f64,
m10: f64,
) -> WasmCompressor {
) -> Result<WasmCompressor, JsValue> {
if speed_rpm <= 0.0 {
return Err(js_sys::Error::new("speed_rpm must be positive").into());
}
if displacement_m3_per_rev <= 0.0 {
return Err(js_sys::Error::new("displacement_m3_per_rev must be positive").into());
}
if !(0.0..=1.0).contains(&efficiency) {
return Err(js_sys::Error::new("efficiency must be between 0.0 and 1.0").into());
}
let coeffs =
entropyk_components::Ahri540Coefficients::new(m1, m2, m3, m4, m5, m6, m7, m8, m9, m10);
let fluid_id = FluidId::new("R410A");
let p = entropyk_core::Pressure::from_bar(10.0);
let fluid_id = FluidId::new(&fluid);
let p = entropyk_core::Pressure::from_bar(5.0);
let h = entropyk_core::Enthalpy::from_joules_per_kg(400000.0);
let suction = Port::new(fluid_id.clone(), p, h);
let discharge = Port::new(fluid_id, p, h);
let discharge = Port::new(fluid_id.clone(), p, h);
let comp =
entropyk_components::Compressor::new(coeffs, suction, discharge, 2900.0, 0.0001, 0.85)
.unwrap();
let comp = entropyk_components::Compressor::new(
coeffs,
suction,
discharge,
speed_rpm,
displacement_m3_per_rev,
efficiency,
)
.map_err(|e| js_sys::Error::new(&format!("Compressor creation failed: {}", e)))?;
// Connect to dummy ports to get Connected state
let suction_p = Port::new(FluidId::new("R410A"), p, h);
let discharge_p = Port::new(FluidId::new("R410A"), p, h);
let connected = comp.connect(suction_p, discharge_p).unwrap();
let suction_p = Port::new(fluid_id.clone(), p, h);
let discharge_p = Port::new(fluid_id, p, h);
let connected = comp
.connect(suction_p, discharge_p)
.map_err(|e| js_sys::Error::new(&format!("Compressor connect failed: {}", e)))?;
WasmCompressor { inner: connected }
Ok(WasmCompressor { inner: connected })
}
/// Convert to a generic WasmComponent.
pub fn into_component(self) -> WasmComponent {
WasmComponent {
inner: Box::new(self.inner),
name: "Compressor".to_string(),
}
}
}
@@ -81,18 +108,27 @@ pub struct WasmCondenser {
#[wasm_bindgen]
impl WasmCondenser {
/// Create a new condenser with thermal conductance UA.
/// Create a new condenser with thermal conductance UA (W/K).
///
/// Rejects NaN, negative, zero, and infinite values.
#[wasm_bindgen(constructor)]
pub fn new(ua: f64) -> WasmCondenser {
WasmCondenser {
inner: entropyk_components::Condenser::new(ua),
pub fn new(ua: f64) -> Result<WasmCondenser, JsValue> {
if ua.is_nan() || ua.is_infinite() {
return Err(js_sys::Error::new("UA must be a finite number").into());
}
if ua <= 0.0 {
return Err(js_sys::Error::new("UA must be positive").into());
}
Ok(WasmCondenser {
inner: entropyk_components::Condenser::new(ua),
})
}
/// Convert to a generic WasmComponent.
pub fn into_component(self) -> WasmComponent {
WasmComponent {
inner: Box::new(self.inner),
name: "Condenser".to_string(),
}
}
}
@@ -105,18 +141,27 @@ pub struct WasmEvaporator {
#[wasm_bindgen]
impl WasmEvaporator {
/// Create a new evaporator with thermal conductance UA.
/// Create a new evaporator with thermal conductance UA (W/K).
///
/// Rejects NaN, negative, zero, and infinite values.
#[wasm_bindgen(constructor)]
pub fn new(ua: f64) -> WasmEvaporator {
WasmEvaporator {
inner: entropyk_components::Evaporator::new(ua),
pub fn new(ua: f64) -> Result<WasmEvaporator, JsValue> {
if ua.is_nan() || ua.is_infinite() {
return Err(js_sys::Error::new("UA must be a finite number").into());
}
if ua <= 0.0 {
return Err(js_sys::Error::new("UA must be positive").into());
}
Ok(WasmEvaporator {
inner: entropyk_components::Evaporator::new(ua),
})
}
/// Convert to a generic WasmComponent.
pub fn into_component(self) -> WasmComponent {
WasmComponent {
inner: Box::new(self.inner),
name: "Evaporator".to_string(),
}
}
}
@@ -124,33 +169,42 @@ impl WasmEvaporator {
/// WASM wrapper for ExpansionValve.
#[wasm_bindgen]
pub struct WasmExpansionValve {
pub(crate) inner: entropyk_components::ExpansionValve<Connected>,
pub(crate) inner: entropyk_components::ExpansionValve<entropyk_components::port::Connected>,
}
#[wasm_bindgen]
impl WasmExpansionValve {
/// Create a new expansion valve.
///
/// # Arguments
/// * `fluid` - Fluid identifier (e.g. "R134a", "R410A")
/// * `capacity` - Optional valve capacity (kW). Pass 0.0 for no capacity limit.
#[wasm_bindgen(constructor)]
pub fn new() -> WasmExpansionValve {
let fluid_id = FluidId::new("R410A");
pub fn new(fluid: String, capacity: f64) -> Result<WasmExpansionValve, JsValue> {
let fluid_id = FluidId::new(&fluid);
let p = entropyk_core::Pressure::from_bar(10.0);
let h = entropyk_core::Enthalpy::from_joules_per_kg(400000.0);
let inlet = Port::new(fluid_id.clone(), p, h);
let outlet = Port::new(fluid_id, p, h);
let outlet = Port::new(fluid_id.clone(), p, h);
let valve = entropyk_components::ExpansionValve::new(inlet, outlet, Some(1.0)).unwrap();
let cap = if capacity > 0.0 { Some(capacity) } else { None };
let valve = entropyk_components::ExpansionValve::new(inlet, outlet, cap)
.map_err(|e| js_sys::Error::new(&format!("ExpansionValve creation failed: {}", e)))?;
let inlet_p = Port::new(FluidId::new("R410A"), p, h);
let outlet_p = Port::new(FluidId::new("R410A"), p, h);
let connected = valve.connect(inlet_p, outlet_p).unwrap();
let inlet_p = Port::new(fluid_id.clone(), p, h);
let outlet_p = Port::new(fluid_id, p, h);
let connected = valve
.connect(inlet_p, outlet_p)
.map_err(|e| js_sys::Error::new(&format!("ExpansionValve connect failed: {}", e)))?;
WasmExpansionValve { inner: connected }
Ok(WasmExpansionValve { inner: connected })
}
/// Convert to a generic WasmComponent.
pub fn into_component(self) -> WasmComponent {
WasmComponent {
inner: Box::new(self.inner),
name: "ExpansionValve".to_string(),
}
}
}
@@ -158,38 +212,60 @@ impl WasmExpansionValve {
/// WASM wrapper for Pipe.
#[wasm_bindgen]
pub struct WasmPipe {
pub(crate) inner: entropyk_components::Pipe<Connected>,
pub(crate) inner: entropyk_components::Pipe<entropyk_components::port::Connected>,
}
#[wasm_bindgen]
impl WasmPipe {
/// Create a new pipe.
///
/// # Arguments
/// * `fluid` - Fluid identifier (e.g. "Water")
/// * `length` - Pipe length in meters (must be positive)
/// * `diameter` - Pipe inner diameter in meters (must be positive)
/// * `density` - Fluid density in kg/m³ (default: 1000.0 for water)
/// * `viscosity` - Fluid dynamic viscosity in Pa·s (default: 0.001 for water)
#[wasm_bindgen(constructor)]
pub fn new(length: f64, diameter: f64) -> WasmPipe {
let geometry = entropyk_components::PipeGeometry::smooth(length, diameter).unwrap();
let fluid_id = FluidId::new("Water");
pub fn new(
fluid: String,
length: f64,
diameter: f64,
density: f64,
viscosity: f64,
) -> Result<WasmPipe, JsValue> {
if length.is_nan() || length <= 0.0 {
return Err(js_sys::Error::new("Pipe length must be a positive number").into());
}
if diameter.is_nan() || diameter <= 0.0 {
return Err(js_sys::Error::new("Pipe diameter must be a positive number").into());
}
let geometry = entropyk_components::PipeGeometry::smooth(length, diameter)
.map_err(|e| js_sys::Error::new(&format!("Invalid pipe geometry: {}", e)))?;
let fluid_id = FluidId::new(&fluid);
let p = entropyk_core::Pressure::from_bar(1.0);
let h = entropyk_core::Enthalpy::from_joules_per_kg(100000.0);
let inlet = Port::new(fluid_id.clone(), p, h);
let outlet = Port::new(fluid_id, p, h);
let outlet = Port::new(fluid_id.clone(), p, h);
let pipe = entropyk_components::Pipe::new(
geometry, inlet, outlet, 1000.0, // Default density
0.001, // Default viscosity
)
.unwrap();
let pipe = entropyk_components::Pipe::new(geometry, inlet, outlet, density, viscosity)
.map_err(|e| js_sys::Error::new(&format!("Pipe creation failed: {}", e)))?;
let inlet_p = Port::new(FluidId::new("Water"), p, h);
let outlet_p = Port::new(FluidId::new("Water"), p, h);
let connected = pipe.connect(inlet_p, outlet_p).unwrap();
let inlet_p = Port::new(fluid_id.clone(), p, h);
let outlet_p = Port::new(fluid_id, p, h);
let connected = pipe
.connect(inlet_p, outlet_p)
.map_err(|e| js_sys::Error::new(&format!("Pipe connect failed: {}", e)))?;
WasmPipe { inner: connected }
Ok(WasmPipe { inner: connected })
}
/// Convert to a generic WasmComponent.
pub fn into_component(self) -> WasmComponent {
WasmComponent {
inner: Box::new(self.inner),
name: "Pipe".to_string(),
}
}
}

View File

@@ -1,10 +1,26 @@
//! Error handling for WASM bindings.
//!
//! Maps errors to JavaScript exceptions with human-readable messages.
//! Maps internal error types to JavaScript exceptions with human-readable messages.
use entropyk::ThermoError;
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())
}
/// Map ThermoError to a human-readable JsValue.
pub fn thermo_error_to_js(e: ThermoError) -> JsValue {
let msg = match &e {
ThermoError::Component(err) => format!("Component error: {}", err),
ThermoError::Solver(err) => format!("Solver error: {}", err),
ThermoError::Fluid(err) => format!("Fluid error: {}", err),
ThermoError::Topology(err) => format!("Topology error: {}", err),
ThermoError::AddEdge(err) => format!("Edge error: {}", err),
ThermoError::Connection(err) => format!("Connection error: {}", err),
ThermoError::Constraint(err) => format!("Constraint error: {}", err),
_ => format!("{}", e),
};
js_sys::Error::new(&msg).into()
}

View File

@@ -2,12 +2,13 @@
//!
//! Provides JavaScript-friendly wrappers for the solver and system.
use crate::backend::global_backend;
use crate::components::WasmComponent;
use crate::types::{WasmConvergedState, WasmThermoState};
use entropyk_components::port::{FluidId, Port};
use entropyk_components::Component;
use entropyk_fluids::FluidBackend;
use entropyk_solver::{
ConvergedState, FallbackSolver, NewtonConfig, PicardConfig, Solver, SolverStrategy, System,
ConvergenceStatus, ConvergedState, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig,
Solver, SolverStrategy, System,
};
use petgraph::graph::NodeIndex;
use std::cell::RefCell;
@@ -70,9 +71,17 @@ impl WasmPicardConfig {
self.inner.max_iterations = max;
}
/// Set relaxation factor.
/// Set relaxation factor. Must be in (0, 1]. Values outside this range are clamped.
pub fn set_relaxation_factor(&mut self, omega: f64) {
self.inner.relaxation_factor = omega;
if omega.is_nan() || omega <= 0.0 {
tracing::warn!("Invalid relaxation factor {}, clamping to 0.1", omega);
self.inner.relaxation_factor = 0.1;
} else if omega > 1.0 {
tracing::warn!("Relaxation factor {} > 1.0, clamping to 1.0", omega);
self.inner.relaxation_factor = 1.0;
} else {
self.inner.relaxation_factor = omega;
}
}
}
@@ -86,8 +95,7 @@ impl Default for WasmPicardConfig {
#[wasm_bindgen]
#[derive(Clone, Debug)]
pub struct WasmFallbackConfig {
newton_config: NewtonConfig,
picard_config: PicardConfig,
inner: FallbackConfig,
}
#[wasm_bindgen]
@@ -96,15 +104,23 @@ impl WasmFallbackConfig {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
WasmFallbackConfig {
newton_config: NewtonConfig::default(),
picard_config: PicardConfig::default(),
inner: FallbackConfig::default(),
}
}
/// Set timeout (placeholder for compatibility).
pub fn timeout_ms(&mut self, _ms: u64) {
// FallbackConfig currently doesn't have a direct timeout field in Rust
// but it's used in the README example. We'll add this setter for API compatibility.
/// Enable or disable automatic fallback to Picard on divergence.
pub fn set_fallback_enabled(&mut self, enabled: bool) {
self.inner.fallback_enabled = enabled;
}
/// Set residual threshold for returning to Newton from Picard.
pub fn set_return_to_newton_threshold(&mut self, threshold: f64) {
self.inner.return_to_newton_threshold = threshold;
}
/// Set maximum number of solver switches before staying on current solver.
pub fn set_max_fallback_switches(&mut self, max: usize) {
self.inner.max_fallback_switches = max;
}
}
@@ -133,7 +149,7 @@ impl WasmSystem {
})
}
/// Add a component to the system.
/// Add a component to the system. Returns the node index.
pub fn add_component(&mut self, component: WasmComponent) -> usize {
self.inner
.borrow_mut()
@@ -142,7 +158,28 @@ impl WasmSystem {
}
/// Add an edge between components.
///
/// Returns an error if node indices are out of bounds.
pub fn add_edge(&mut self, from_idx: usize, to_idx: usize) -> Result<(), JsValue> {
let system = self.inner.borrow();
let node_count = system.node_count();
drop(system);
if from_idx >= node_count {
return Err(js_sys::Error::new(&format!(
"from_idx {} is out of bounds (system has {} nodes)",
from_idx, node_count
))
.into());
}
if to_idx >= node_count {
return Err(js_sys::Error::new(&format!(
"to_idx {} is out of bounds (system has {} nodes)",
to_idx, node_count
))
.into());
}
self.inner
.borrow_mut()
.add_edge(
@@ -162,8 +199,8 @@ impl WasmSystem {
}
/// Solve the system with fallback strategy.
pub fn solve(&mut self, _config: WasmFallbackConfig) -> Result<WasmConvergedState, JsValue> {
let mut solver = FallbackSolver::default_solver();
pub fn solve(&mut self, config: WasmFallbackConfig) -> Result<WasmConvergedState, JsValue> {
let mut solver = FallbackSolver::new(config.inner);
let state = solver
.solve(&mut *self.inner.borrow_mut())
.map_err(|e| js_sys::Error::new(&e.to_string()))?;
@@ -197,6 +234,10 @@ impl WasmSystem {
}
/// Get thermodynamic state for a specific node (after solve).
///
/// Uses the global TabularBackend to compute full thermodynamic properties
/// (temperature, entropy, density, phase, quality, etc.) from the solver's
/// pressure and enthalpy state.
pub fn get_node_result(&self, node_idx: usize) -> Result<WasmThermoState, JsValue> {
let system = self.inner.borrow();
let state_ref = self.last_state.borrow();
@@ -204,39 +245,45 @@ impl WasmSystem {
js_sys::Error::new("System must be solved before calling get_node_result")
})?;
// Use traverse_for_jacobian to find the component and its edge indices
for (idx, component, edges) in system.traverse_for_jacobian() {
if idx.index() == node_idx {
if let Some((_edge_idx, p_idx, h_idx)) = edges.first() {
if *p_idx >= state.len() || *h_idx >= state.len() {
return Err(js_sys::Error::new(&format!(
"State index out of bounds: p_idx={}, h_idx={}, state_len={}",
p_idx, h_idx, state.len()
))
.into());
}
let p = state[*p_idx];
let h = state[*h_idx];
// Simple heuristic to get the fluid: look at ports
let ports = component.get_ports();
let fluid_id = if !ports.is_empty() {
entropyk_fluids::FluidId::new(ports[0].fluid_id().as_str())
} else {
entropyk_fluids::FluidId::new("R410A") // Fallback
return Err(js_sys::Error::new(
"Component has no ports — cannot determine fluid",
)
.into());
};
// In a real implementation, we would use the system's backend to resolve T and properties.
// For now, we return a thermo state with P and h, which is what the user mostly needs.
// The WasmThermoState::from implementation we fixed will handle the conversion.
let thermo = entropyk_fluids::ThermoState {
fluid: fluid_id,
pressure: entropyk_core::Pressure::from_pascals(p),
temperature: entropyk_core::Temperature::from_kelvin(300.0), // Placeholder
enthalpy: entropyk_core::Enthalpy::from_joules_per_kg(h),
entropy: entropyk_fluids::Entropy::from_joules_per_kg_kelvin(0.0),
density: 1.0,
phase: entropyk_fluids::Phase::Unknown,
quality: None,
superheat: None,
subcooling: None,
t_bubble: None,
t_dew: None,
};
return Ok(thermo.into());
let backend = global_backend();
let thermo_state = backend
.full_state(
fluid_id,
entropyk_core::Pressure::from_pascals(p),
entropyk_core::Enthalpy::from_joules_per_kg(h),
)
.map_err(|e| {
js_sys::Error::new(&format!(
"Failed to compute thermodynamic state: {}",
e
))
})?;
return Ok(thermo_state.into());
}
}
}
@@ -254,18 +301,18 @@ impl WasmSystem {
}
}
impl Default for WasmSystem {
fn default() -> Self {
Self::new().expect("Failed to create default system")
}
}
impl From<&ConvergedState> for WasmConvergedState {
fn from(state: &ConvergedState) -> Self {
let status_str = match state.status {
ConvergenceStatus::Converged => "Converged",
ConvergenceStatus::TimedOutWithBestState => "TimedOut",
ConvergenceStatus::ControlSaturation => "ControlSaturation",
};
WasmConvergedState {
converged: state.is_converged(),
iterations: state.iterations,
final_residual: state.final_residual,
status: status_str.to_string(),
}
}
}

View File

@@ -16,17 +16,29 @@ pub struct WasmPressure {
#[wasm_bindgen]
impl WasmPressure {
/// Create pressure from Pascals.
/// Create pressure from Pascals. Rejects NaN and negative values.
#[wasm_bindgen(constructor)]
pub fn new(pascals: f64) -> Self {
WasmPressure { pascals }
pub fn new(pascals: f64) -> Result<WasmPressure, JsValue> {
if pascals.is_nan() {
return Err(js_sys::Error::new("Pressure cannot be NaN").into());
}
if pascals < 0.0 {
return Err(js_sys::Error::new("Pressure cannot be negative").into());
}
Ok(WasmPressure { pascals })
}
/// Create pressure from bar.
pub fn from_bar(bar: f64) -> Self {
WasmPressure {
pascals: bar * 100_000.0,
/// Create pressure from bar. Rejects NaN and negative values.
pub fn from_bar(bar: f64) -> Result<Self, JsValue> {
if bar.is_nan() {
return Err(js_sys::Error::new("Pressure cannot be NaN").into());
}
if bar < 0.0 {
return Err(js_sys::Error::new("Pressure cannot be negative").into());
}
Ok(WasmPressure {
pascals: bar * 100_000.0,
})
}
/// Get pressure in Pascals.
@@ -41,10 +53,7 @@ impl WasmPressure {
/// Convert to JSON string.
pub fn toJson(&self) -> Result<String, JsValue> {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
self.serialize(&serializer)
.map(|v| v.as_string().unwrap_or_default())
.map_err(|e| js_sys::Error::new(&e.to_string()).into())
serde_json::to_string(self).map_err(|e| js_sys::Error::new(&e.to_string()).into())
}
}
@@ -71,17 +80,21 @@ pub struct WasmTemperature {
#[wasm_bindgen]
impl WasmTemperature {
/// Create temperature from Kelvin.
/// Create temperature from Kelvin. Rejects NaN and negative values.
#[wasm_bindgen(constructor)]
pub fn new(kelvin: f64) -> Self {
WasmTemperature { kelvin }
pub fn new(kelvin: f64) -> Result<Self, JsValue> {
if kelvin.is_nan() {
return Err(js_sys::Error::new("Temperature cannot be NaN").into());
}
if kelvin < 0.0 {
return Err(js_sys::Error::new("Temperature cannot be negative (Kelvin)").into());
}
Ok(WasmTemperature { kelvin })
}
/// Create temperature from Celsius.
pub fn from_celsius(celsius: f64) -> Self {
WasmTemperature {
kelvin: celsius + 273.15,
}
pub fn from_celsius(celsius: f64) -> Result<Self, JsValue> {
Self::new(celsius + 273.15)
}
/// Get temperature in Kelvin.
@@ -96,10 +109,7 @@ impl WasmTemperature {
/// Convert to JSON string.
pub fn toJson(&self) -> Result<String, JsValue> {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
self.serialize(&serializer)
.map(|v| v.as_string().unwrap_or_default())
.map_err(|e| js_sys::Error::new(&e.to_string()).into())
serde_json::to_string(self).map_err(|e| js_sys::Error::new(&e.to_string()).into())
}
}
@@ -126,17 +136,18 @@ pub struct WasmEnthalpy {
#[wasm_bindgen]
impl WasmEnthalpy {
/// Create enthalpy from J/kg.
/// Create enthalpy from J/kg. Rejects NaN.
#[wasm_bindgen(constructor)]
pub fn new(joules_per_kg: f64) -> Self {
WasmEnthalpy { joules_per_kg }
pub fn new(joules_per_kg: f64) -> Result<Self, JsValue> {
if joules_per_kg.is_nan() {
return Err(js_sys::Error::new("Enthalpy cannot be NaN").into());
}
Ok(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,
}
pub fn from_kj_per_kg(kj_per_kg: f64) -> Result<Self, JsValue> {
Self::new(kj_per_kg * 1000.0)
}
/// Get enthalpy in J/kg.
@@ -151,10 +162,7 @@ impl WasmEnthalpy {
/// Convert to JSON string.
pub fn toJson(&self) -> Result<String, JsValue> {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
self.serialize(&serializer)
.map(|v| v.as_string().unwrap_or_default())
.map_err(|e| js_sys::Error::new(&e.to_string()).into())
serde_json::to_string(self).map_err(|e| js_sys::Error::new(&e.to_string()).into())
}
}
@@ -181,10 +189,13 @@ pub struct WasmMassFlow {
#[wasm_bindgen]
impl WasmMassFlow {
/// Create mass flow from kg/s.
/// Create mass flow from kg/s. Rejects NaN.
#[wasm_bindgen(constructor)]
pub fn new(kg_per_s: f64) -> Self {
WasmMassFlow { kg_per_s }
pub fn new(kg_per_s: f64) -> Result<Self, JsValue> {
if kg_per_s.is_nan() {
return Err(js_sys::Error::new("Mass flow cannot be NaN").into());
}
Ok(WasmMassFlow { kg_per_s })
}
/// Get mass flow in kg/s.
@@ -194,10 +205,7 @@ impl WasmMassFlow {
/// Convert to JSON string.
pub fn toJson(&self) -> Result<String, JsValue> {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
self.serialize(&serializer)
.map(|v| v.as_string().unwrap_or_default())
.map_err(|e| js_sys::Error::new(&e.to_string()).into())
serde_json::to_string(self).map_err(|e| js_sys::Error::new(&e.to_string()).into())
}
}
@@ -217,11 +225,13 @@ impl From<WasmMassFlow> for MassFlow {
/// WASM wrapper for thermodynamic state (result).
#[wasm_bindgen]
#[derive(Clone, Copy, Debug, Serialize)]
#[derive(Clone, Debug, Serialize)]
pub struct WasmThermoState {
pub pressure: WasmPressure,
pub temperature: WasmTemperature,
pub enthalpy: WasmEnthalpy,
/// Mass flow is not carried by ThermoState upstream; only populated
/// when available from the solver context.
pub mass_flow: WasmMassFlow,
}
@@ -229,47 +239,62 @@ pub struct WasmThermoState {
impl WasmThermoState {
/// Convert to JSON string.
pub fn toJson(&self) -> Result<String, JsValue> {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
self.serialize(&serializer)
.map(|v| v.as_string().unwrap_or_default())
.map_err(|e| js_sys::Error::new(&e.to_string()).into())
serde_json::to_string(self).map_err(|e| js_sys::Error::new(&e.to_string()).into())
}
}
impl From<entropyk_fluids::ThermoState> for WasmThermoState {
fn from(s: entropyk_fluids::ThermoState) -> Self {
WasmThermoState {
pressure: WasmPressure::new(s.pressure.to_pascals()),
temperature: WasmTemperature::new(s.temperature.to_kelvin()),
enthalpy: WasmEnthalpy::new(s.enthalpy.to_joules_per_kg()),
mass_flow: WasmMassFlow::new(0.0),
pressure: WasmPressure::from(s.pressure),
temperature: WasmTemperature::from(s.temperature),
enthalpy: WasmEnthalpy::from(s.enthalpy),
mass_flow: WasmMassFlow { kg_per_s: 0.0 },
}
}
}
/// WASM wrapper for converged state (solver result).
#[wasm_bindgen]
#[derive(Clone, Copy, Debug, Serialize)]
#[derive(Clone, Debug)]
pub struct WasmConvergedState {
/// Convergence status
/// Whether the solver converged
pub converged: bool,
/// Number of iterations
pub iterations: usize,
/// Final residual
/// Final residual norm
pub final_residual: f64,
pub(crate) status: String,
}
#[wasm_bindgen]
impl WasmConvergedState {
/// Get solver status: "Converged", "TimedOut", "ControlSaturation", or "Unknown".
pub fn status(&self) -> String {
self.status.clone()
}
/// Convert to JSON string.
pub fn toJson(&self) -> Result<String, JsValue> {
let serializer = serde_wasm_bindgen::Serializer::json_compatible();
self.serialize(&serializer)
.map(|v| v.as_string().unwrap_or_default())
.map_err(|e| js_sys::Error::new(&e.to_string()).into())
serde_json::to_string(&SerializableConvergedState {
converged: self.converged,
iterations: self.iterations,
final_residual: self.final_residual,
status: &self.status,
})
.map_err(|e| js_sys::Error::new(&e.to_string()).into())
}
}
/// Serializable representation of WasmConvergedState.
#[derive(Serialize)]
struct SerializableConvergedState<'a> {
converged: bool,
iterations: usize,
final_residual: f64,
status: &'a str,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -277,28 +302,43 @@ mod tests {
#[wasm_bindgen_test]
fn test_pressure_creation() {
let p = WasmPressure::from_bar(1.0);
let p = WasmPressure::from_bar(1.0).unwrap();
assert!((p.pascals() - 100000.0).abs() < 1e-6);
assert!((p.bar() - 1.0).abs() < 1e-9);
}
#[wasm_bindgen_test]
fn test_pressure_rejects_negative() {
assert!(WasmPressure::new(-1.0).is_err());
}
#[wasm_bindgen_test]
fn test_pressure_rejects_nan() {
assert!(WasmPressure::new(f64::NAN).is_err());
}
#[wasm_bindgen_test]
fn test_temperature_creation() {
let t = WasmTemperature::from_celsius(25.0);
let t = WasmTemperature::from_celsius(25.0).unwrap();
assert!((t.kelvin() - 298.15).abs() < 1e-6);
assert!((t.celsius() - 25.0).abs() < 1e-9);
}
#[wasm_bindgen_test]
fn test_temperature_rejects_negative() {
assert!(WasmTemperature::new(-1.0).is_err());
}
#[wasm_bindgen_test]
fn test_enthalpy_creation() {
let h = WasmEnthalpy::from_kj_per_kg(400.0);
let h = WasmEnthalpy::from_kj_per_kg(400.0).unwrap();
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);
let m = WasmMassFlow::new(0.1).unwrap();
assert!((m.kg_per_s() - 0.1).abs() < 1e-9);
}
}