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

@@ -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();