Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -4,11 +4,10 @@
//! API ergonomics using real component types.
use entropyk::{System, SystemBuilder, ThermoError};
use entropyk_components::{
Component, ComponentError, JacobianBuilder, ResidualVector,
};
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector};
struct MockComponent {
#[allow(dead_code)] // Identifies the fixture instance; not asserted in these tests.
name: &'static str,
n_eqs: usize,
}
@@ -143,7 +142,7 @@ fn test_builder_into_inner() {
#[test]
fn test_direct_system_api() {
let mut system = System::new();
let idx = system.add_component(Box::new(MockComponent {
let _idx = system.add_component(Box::new(MockComponent {
name: "test",
n_eqs: 2,
}));

View File

@@ -5,6 +5,7 @@
use entropyk::{
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId, SystemBuilder,
ThermoError, TopologyError,
};
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector,
@@ -49,18 +50,13 @@ fn test_builder_constraints_link_and_validate_dof() {
},
5.0,
);
let valve = BoundedVariable::new(
BoundedVariableId::new("valve"),
0.5,
0.0,
1.0,
)
.expect("valid bounds");
let valve =
BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).expect("valid bounds");
// Minimal topology: 2 nodes, 1 edge → 2 edge unknowns (P,h). With 1 constraint and 1 control
// we need 3 equations total: 2 component eqs + 1 constraint = 3 = 2 + 1 unknowns.
// Minimal topology: 2 nodes, 1 edge → 3 edge unknowns (ṁ,P,h). With 1 constraint and 1 control
// we need 4 equations total: 3 component eqs + 1 constraint = 4 = 3 + 1 unknowns.
let system = SystemBuilder::new()
.component("evap", Box::new(MockComponent { n_eqs: 1 }))
.component("evap", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.component("other", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
@@ -103,16 +99,16 @@ fn test_builder_dof_imbalance_two_constraints_one_control() {
},
3.0,
);
let valve = BoundedVariable::new(
BoundedVariableId::new("valve"),
0.5,
0.0,
1.0,
)
.expect("valid bounds");
let valve =
BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).expect("valid bounds");
let system = SystemBuilder::new()
.component("evap", Box::new(MockComponent { n_eqs: 1 }))
// Same topology as test 1 (3 component eqs total), but 2 constraints and 1 control →
// 3+2=5 equations vs 3+1=4 unknowns → over-constrained. Since `System::finalize()`
// enforces the DoF gate, `build()` itself now rejects the system with
// `TopologyError::DofImbalance` (the post-build `validate_inverse_control_dof()`
// check is unreachable for over-constrained systems).
let result = SystemBuilder::new()
.component("evap", Box::new(MockComponent { n_eqs: 2 }))
.unwrap()
.component("other", Box::new(MockComponent { n_eqs: 1 }))
.unwrap()
@@ -129,14 +125,17 @@ fn test_builder_dof_imbalance_two_constraints_one_control() {
&BoundedVariableId::new("valve"),
)
.unwrap()
.build()
.expect("build should succeed");
.build();
// DoF validation should fail: 2 constraints but only 1 control (unbalanced).
let dof_result = system.validate_inverse_control_dof();
assert!(
dof_result.is_err(),
"validate_inverse_control_dof should fail with 2 constraints and 1 control, got: {:?}",
dof_result
);
// DoF gate should reject the build: 2 constraints but only 1 control (unbalanced).
match result {
Err(ThermoError::Topology(TopologyError::DofImbalance { message })) => {
assert!(
message.contains("over-constrained"),
"expected an over-constrained DoF report, got: {message}"
);
}
Err(other) => panic!("expected DofImbalance error from build(), got: {other}"),
Ok(_) => panic!("build() should fail with DofImbalance (2 constraints, 1 control)"),
}
}

View File

@@ -4,9 +4,7 @@
//! finalized, and exposes the expected circuit topology.
use entropyk::{CircuitId, SystemBuilder};
use entropyk_components::{
Component, ComponentError, JacobianBuilder, ResidualVector,
};
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector};
struct MockComponent {
n_eqs: usize,

View File

@@ -152,7 +152,10 @@ fn test_edge_with_ports_unknown_port_name_error() {
}) = result
{
assert_eq!(component, "a");
assert!(port_name.starts_with("bogus_port"), "port_name should start with the port name, got: {port_name}");
assert!(
port_name.starts_with("bogus_port"),
"port_name should start with the port name, got: {port_name}"
);
} else {
panic!("Expected PortNotFound error");
}
@@ -183,12 +186,15 @@ fn test_edge_with_ports_same_circuit_succeeds() {
#[test]
fn test_build_system_with_port_validated_edges() {
// DoF ledger post-CM1.4: 2-edge chain → 1 branch ṁ + 2×(P,h) = 5 unknowns,
// so component equations must total 5 for the finalize DoF gate (2+2+1).
// The last mock contributes a single equation to keep the system square.
let system = SystemBuilder::new()
.component("a", Box::new(MockComponentWithPorts::new(2)))
.unwrap()
.component("b", Box::new(MockComponentWithPorts::new(2)))
.unwrap()
.component("c", Box::new(MockComponentWithPorts::new(2)))
.component("c", Box::new(MockComponentWithPorts::new(1)))
.unwrap()
.edge_with_ports("a", "outlet", "b", "inlet")
.unwrap()

View File

@@ -1,16 +1,14 @@
//! Integration tests for structured simulation result extraction.
use entropyk::{
extract_simulation_result, SimulationOutcome, SimulationResult, SystemBuilder,
};
use entropyk::{extract_simulation_result, SimulationOutcome, SimulationResult, SystemBuilder};
use entropyk_components::expansion_valve::ExpansionValve;
use entropyk_components::heat_exchanger::{Condenser, Evaporator};
use entropyk_components::heat_exchanger::Evaporator;
use entropyk_components::port::{Disconnected, FluidId, Port};
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, MchxCondenserCoil, Polynomial2D,
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, StateSlice,
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves,
};
use entropyk_core::{Enthalpy, MassFlow, Power, Pressure};
use entropyk_core::{Enthalpy, Power, Pressure};
use entropyk_solver::{ConvergedState, ConvergenceStatus, SimulationMetadata};
use approx::assert_relative_eq;
@@ -53,8 +51,9 @@ fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
let suc = make_connected_port("R134a", 2.93, 405.0);
let dis = make_connected_port("R134a", 10.17, 440.0);
let eco = make_connected_port("R134a", 5.5, 250.0);
let comp = ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
.expect("compressor");
let comp =
ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
.expect("compressor");
// --- Condenser (air-cooled coil at 35°C ambient) ---
let condenser = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
@@ -62,19 +61,31 @@ fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
// --- Expansion valve (fully open) ---
let exv_in = make_disconnected_port("R134a", 10.17, 253.4);
let exv_out = make_disconnected_port("R134a", 2.93, 253.4);
let exv_disconnected = ExpansionValve::new(exv_in, exv_out, Some(1.0)).expect("exv disconnected");
let exv_disconnected =
ExpansionValve::new(exv_in, exv_out, Some(1.0)).expect("exv disconnected");
let exv = exv_disconnected
.connect(make_disconnected_port("R134a", 10.17, 253.4), make_disconnected_port("R134a", 2.93, 253.4))
.connect(
make_disconnected_port("R134a", 10.17, 253.4),
make_disconnected_port("R134a", 2.93, 253.4),
)
.expect("exv connect");
// --- Evaporator (BPHE, T_sat=278.15K, SH=5K) ---
let evaporator = Evaporator::with_superheat(8000.0, 278.15, 5.0);
// Add to circuit 0
let n_comp = sys.add_component_to_circuit(Box::new(comp), CircuitId::ZERO).unwrap();
let n_cond = sys.add_component_to_circuit(Box::new(condenser), CircuitId::ZERO).unwrap();
let n_exv = sys.add_component_to_circuit(Box::new(exv), CircuitId::ZERO).unwrap();
let n_evap = sys.add_component_to_circuit(Box::new(evaporator), CircuitId::ZERO).unwrap();
let n_comp = sys
.add_component_to_circuit(Box::new(comp), CircuitId::ZERO)
.unwrap();
let n_cond = sys
.add_component_to_circuit(Box::new(condenser), CircuitId::ZERO)
.unwrap();
let n_exv = sys
.add_component_to_circuit(Box::new(exv), CircuitId::ZERO)
.unwrap();
let n_evap = sys
.add_component_to_circuit(Box::new(evaporator), CircuitId::ZERO)
.unwrap();
// Register names for extract_simulation_result
sys.register_component_name("compressor", n_comp);
@@ -88,6 +99,12 @@ fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
sys.add_edge(n_exv, n_evap).unwrap();
sys.add_edge(n_evap, n_comp).unwrap();
// DoF gate escape hatch: the real components contribute 6+2+2+2 = 12 equations
// vs 10 unknowns (1 branch ṁ + 4×(P,h) + compressor internal W_shaft) because no
// free actuators (compressor speed, EXV opening) are registered here. This test
// exercises result extraction/JSON serialization, not DoF balancing, so the
// over-constrained system is accepted deliberately (test-only escape hatch).
sys.set_enforce_dof_gate(false);
sys.finalize().expect("system finalize");
// ConvergedState from NIST R134a reference data:
@@ -95,11 +112,14 @@ fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
// T_cond_sat = 40°C → P_sat ≈ 1017000 Pa (10.17 bar)
// h_g(0°C) ≈ 398600 J/kg, h_f(40°C) ≈ 256400 J/kg
// With SH=5K and SC=3K
// CM1.4: 1 series branch + 4 × (P, h) = 9 elements.
// [ṁ_branch, P_e0, h_e0, P_e1, h_e1, P_e2, h_e2, P_e3, h_e3]
let state = vec![
0.05, // ṁ branch (shared, ~0.05 kg/s)
1017000.0, 440000.0, // edge 0: comp→cond (discharge, superheated ~440 kJ/kg)
1000000.0, 250000.0, // edge 1: cond→exv (subcooled liquid ~250 kJ/kg, ~3K SC)
292800.0, 250000.0, // edge 2: exv→evap (isenthalpic expansion, same h)
285000.0, 405000.0, // edge 3: evap→comp (superheated ~5K above sat)
292800.0, 250000.0, // edge 2: exv→evap (isenthalpic expansion, same h)
285000.0, 405000.0, // edge 3: evap→comp (superheated ~5K above sat)
];
let converged = ConvergedState::new(
@@ -217,6 +237,7 @@ impl Component for MockPipe {
// ─────────────────────────────────────────────────────────────────────────────
/// Helper: build a realistic 4-component vapor compression cycle with mock components.
#[allow(dead_code)] // Reusable cycle fixture for result-serialization tests.
fn build_realistic_cycle() -> (entropyk_solver::System, ConvergedState) {
let system = SystemBuilder::new()
.component("compressor", Box::new(MockCompressor))
@@ -238,13 +259,16 @@ fn build_realistic_cycle() -> (entropyk_solver::System, ConvergedState) {
.build()
.expect("build system");
// R410A-like state vector: 4 edges × 2 (P, h)
// Realistic values: high side ~24 bar, low side ~8 bar
// CM1.4 layout: 1 series branch + 4 × (P, h) = 9 elements
// [ṁ_branch, P_e0, h_e0, P_e1, h_e1, P_e2, h_e2, P_e3, h_e3]
// Realistic R410A values: high side ~24 bar, low side ~8 bar
let state = vec![
2400000.0, 440000.0, // edge 0: compressor → condenser (discharge, high P, superheated)
0.05, // ṁ branch (shared)
2400000.0,
440000.0, // edge 0: compressor → condenser (discharge, high P, superheated)
2350000.0, 280000.0, // edge 1: condenser → expansion (subcooled liquid)
800000.0, 260000.0, // edge 2: expansion → evaporator (two-phase, low P)
780000.0, 400000.0, // edge 3: evaporator → compressor (superheated vapor, low P)
800000.0, 260000.0, // edge 2: expansion → evaporator (two-phase, low P)
780000.0, 400000.0, // edge 3: evaporator → compressor (superheated vapor, low P)
];
let converged = ConvergedState::new(
@@ -280,9 +304,10 @@ fn build_test_system() -> (entropyk_solver::System, ConvergedState) {
.build()
.expect("build system");
// Create a fake converged state with 4 edges = 8 state variables
// [P0, h0, P1, h1, P2, h2, P3, h3]
// CM1.4 layout: 1 series branch + 4 × (P, h) = 9 elements
// [ṁ_branch, P_e0, h_e0, P_e1, h_e1, P_e2, h_e2, P_e3, h_e3]
let state = vec![
0.05, // ṁ branch (shared)
500000.0, 450000.0, // edge 0: comp -> pipe1 (high pressure)
490000.0, 440000.0, // edge 1: pipe1 -> evap
200000.0, 250000.0, // edge 2: evap -> pipe2 (low pressure)
@@ -360,14 +385,22 @@ fn test_extract_per_edge_results() {
let result = extract_simulation_result(&system, &converged);
// Edge 0: comp -> pipe1 (high pressure side)
let edge0 = result.edges.iter().find(|e| e.edge_id == 0).expect("edge 0");
let edge0 = result
.edges
.iter()
.find(|e| e.edge_id == 0)
.expect("edge 0");
assert_relative_eq!(edge0.pressure_pa, 500000.0);
assert_relative_eq!(edge0.enthalpy_j_kg, 450000.0);
assert_eq!(edge0.source.as_deref(), Some("comp"));
assert_eq!(edge0.target.as_deref(), Some("pipe1"));
// Edge 2: evap -> pipe2 (low pressure side)
let edge2 = result.edges.iter().find(|e| e.edge_id == 2).expect("edge 2");
let edge2 = result
.edges
.iter()
.find(|e| e.edge_id == 2)
.expect("edge 2");
assert_relative_eq!(edge2.pressure_pa, 200000.0);
assert_relative_eq!(edge2.enthalpy_j_kg, 250000.0);
assert_eq!(edge2.source.as_deref(), Some("evap"));
@@ -381,15 +414,9 @@ fn test_system_summary() {
// Evaporator absorbs 10000W (cooling), compressor uses 3000W
assert!(result.summary.total_cooling_capacity_w.is_some());
assert_relative_eq!(
result.summary.total_cooling_capacity_w.unwrap(),
10000.0
);
assert_relative_eq!(result.summary.total_cooling_capacity_w.unwrap(), 10000.0);
assert!(result.summary.total_compressor_power_w.is_some());
assert_relative_eq!(
result.summary.total_compressor_power_w.unwrap(),
3000.0
);
assert_relative_eq!(result.summary.total_compressor_power_w.unwrap(), 3000.0);
// COP_cooling = 10000 / 3000
assert!(result.summary.cop_cooling.is_some());
@@ -415,13 +442,19 @@ fn test_simulation_result_json_roundtrip() {
let deserialized: SimulationResult =
serde_json::from_str(&json).expect("deserialize should succeed");
assert_eq!(result.status, deserialized.status);
assert_eq!(result.convergence.iterations, deserialized.convergence.iterations);
assert_eq!(
result.convergence.iterations,
deserialized.convergence.iterations
);
assert_relative_eq!(
result.convergence.final_residual,
deserialized.convergence.final_residual,
epsilon = 1e-15
);
assert_eq!(result.convergence.converged, deserialized.convergence.converged);
assert_eq!(
result.convergence.converged,
deserialized.convergence.converged
);
assert_eq!(result.convergence.status, deserialized.convergence.status);
assert_eq!(result.components.len(), deserialized.components.len());
assert_eq!(result.edges.len(), deserialized.edges.len());
@@ -485,20 +518,52 @@ fn test_realistic_cycle_json_output() {
assert!(json.contains("\"expansion_valve\""));
// Compressor should have real component type (not Mock)
let comp = result.components.iter().find(|c| c.name == "compressor").expect("comp");
assert!(comp.component_type.contains("Screw"), "expected ScrewEconomizer, got {}", comp.component_type);
let comp = result
.components
.iter()
.find(|c| c.name == "compressor")
.expect("comp");
assert!(
comp.component_type.contains("Screw"),
"expected ScrewEconomizer, got {}",
comp.component_type
);
// Condenser should be MchxCondenserCoil
let cond = result.components.iter().find(|c| c.name == "condenser").expect("cond");
assert!(cond.component_type.contains("Mchx"), "expected MchxCondenserCoil, got {}", cond.component_type);
let cond = result
.components
.iter()
.find(|c| c.name == "condenser")
.expect("cond");
assert!(
cond.component_type.contains("Mchx"),
"expected MchxCondenserCoil, got {}",
cond.component_type
);
// Expansion valve should have real type
let exv = result.components.iter().find(|c| c.name == "expansion_valve").expect("exv");
assert!(exv.component_type.contains("ExpansionValve"), "expected ExpansionValve, got {}", exv.component_type);
let exv = result
.components
.iter()
.find(|c| c.name == "expansion_valve")
.expect("exv");
assert!(
exv.component_type.contains("ExpansionValve"),
"expected ExpansionValve, got {}",
exv.component_type
);
// Evaporator
let evap = result.components.iter().find(|c| c.name == "evaporator").expect("evap");
assert!(evap.component_type.contains("Evaporator"), "expected Evaporator, got {}", evap.component_type);
let evap = result
.components
.iter()
.find(|c| c.name == "evaporator")
.expect("evap");
assert!(
evap.component_type.contains("Evaporator"),
"expected Evaporator, got {}",
evap.component_type
);
// Check edge pressures are from NIST data
let edge0 = result.edges.iter().find(|e| e.edge_id == 0).unwrap();