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,3 +1,4 @@
use entropyk_components::port::{Connected, FluidId, Port};
/// Integration test: calibrated refrigeration cycle vs synthetic test data.
///
/// Validates that Calib factors correctly scale component outputs and that
@@ -12,85 +13,176 @@
///
/// Energy balance: compressor_work + evaporator_absorption = condenser_rejection ✓
/// Pressure balance: closes for any f_dp ✓
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, MassFlow};
use entropyk_core::{Enthalpy, Pressure};
use entropyk_solver::{
solver::{NewtonConfig, Solver},
system::System,
system::DEFAULT_MASS_FLOW_SEED_KG_S,
};
use entropyk_components::port::{Connected, FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
type CP = Port<Connected>;
// ─── Calibrated mock components ────────────────────────────────────────────────
struct CalibCompressor { port_suc: CP, port_disc: CP, calib: Calib }
struct CalibCompressor {
port_suc: CP,
port_disc: CP,
calib: Calib,
}
impl Component for CalibCompressor {
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
let dh_eff = 75_000.0 * self.calib.f_m * self.calib.f_power;
r[0] = self.port_disc.pressure().to_pascals() - (self.port_suc.pressure().to_pascals() + 1_000_000.0);
r[1] = self.port_disc.enthalpy().to_joules_per_kg() - (self.port_suc.enthalpy().to_joules_per_kg() + dh_eff);
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
let dh_eff = 75_000.0 * self.calib.z_flow * self.calib.z_power;
r[0] = self.port_disc.pressure().to_pascals()
- (self.port_suc.pressure().to_pascals() + 1_000_000.0);
r[1] = self.port_disc.enthalpy().to_joules_per_kg()
- (self.port_suc.enthalpy().to_joules_per_kg() + dh_eff);
Ok(())
}
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
fn n_equations(&self) -> usize { 2 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
fn jacobian_entries(
&self,
_s: &StateSlice,
_j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
Ok(vec![
MassFlow::from_kg_per_s(0.05),
MassFlow::from_kg_per_s(-0.05),
])
}
}
struct CalibCondenser { port_in: CP, port_out: CP, calib: Calib }
struct CalibCondenser {
port_in: CP,
port_out: CP,
calib: Calib,
}
impl Component for CalibCondenser {
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
let dp_eff = 20_000.0 * self.calib.f_dp;
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
let dp_eff = 20_000.0 * self.calib.z_dp;
// Condenser rejects compressor work + evaporator load (energy balance)
let dh_reject = 75_000.0 * self.calib.f_m * self.calib.f_power + 150_000.0 * self.calib.f_ua;
r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - dp_eff);
r[1] = self.port_out.enthalpy().to_joules_per_kg() - (self.port_in.enthalpy().to_joules_per_kg() - dh_reject);
let dh_reject =
75_000.0 * self.calib.z_flow * self.calib.z_power + 150_000.0 * self.calib.z_ua;
r[0] =
self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - dp_eff);
r[1] = self.port_out.enthalpy().to_joules_per_kg()
- (self.port_in.enthalpy().to_joules_per_kg() - dh_reject);
Ok(())
}
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
fn n_equations(&self) -> usize { 2 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
fn jacobian_entries(
&self,
_s: &StateSlice,
_j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
Ok(vec![
MassFlow::from_kg_per_s(0.05),
MassFlow::from_kg_per_s(-0.05),
])
}
}
struct CalibValve { port_in: CP, port_out: CP, calib: Calib }
struct CalibValve {
port_in: CP,
port_out: CP,
calib: Calib,
}
impl Component for CalibValve {
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
let dp_eff = 1_000_000.0 - 20_000.0 * self.calib.f_dp;
r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - dp_eff);
r[1] = self.port_out.enthalpy().to_joules_per_kg() - self.port_in.enthalpy().to_joules_per_kg();
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
let dp_eff = 1_000_000.0 - 20_000.0 * self.calib.z_dp;
r[0] =
self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - dp_eff);
r[1] = self.port_out.enthalpy().to_joules_per_kg()
- self.port_in.enthalpy().to_joules_per_kg();
Ok(())
}
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
fn n_equations(&self) -> usize { 2 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
fn jacobian_entries(
&self,
_s: &StateSlice,
_j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
Ok(vec![
MassFlow::from_kg_per_s(0.05),
MassFlow::from_kg_per_s(-0.05),
])
}
}
struct CalibEvaporator { port_in: CP, port_out: CP, calib: Calib }
struct CalibEvaporator {
port_in: CP,
port_out: CP,
calib: Calib,
}
impl Component for CalibEvaporator {
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
let dh_eff = 150_000.0 * self.calib.f_ua;
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
let dh_eff = 150_000.0 * self.calib.z_ua;
r[0] = self.port_out.pressure().to_pascals() - self.port_in.pressure().to_pascals();
r[1] = self.port_out.enthalpy().to_joules_per_kg() - (self.port_in.enthalpy().to_joules_per_kg() + dh_eff);
r[1] = self.port_out.enthalpy().to_joules_per_kg()
- (self.port_in.enthalpy().to_joules_per_kg() + dh_eff);
Ok(())
}
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
fn n_equations(&self) -> usize { 2 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
fn jacobian_entries(
&self,
_s: &StateSlice,
_j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
Ok(vec![
MassFlow::from_kg_per_s(0.05),
MassFlow::from_kg_per_s(-0.05),
])
}
}
@@ -99,21 +191,24 @@ fn port(p_pa: f64, h_j_kg: f64) -> CP {
FluidId::new("R134a"),
Pressure::from_pascals(p_pa),
Enthalpy::from_joules_per_kg(h_j_kg),
).connect(Port::new(
)
.connect(Port::new(
FluidId::new("R134a"),
Pressure::from_pascals(p_pa),
Enthalpy::from_joules_per_kg(h_j_kg),
)).unwrap();
))
.unwrap();
connected
}
fn make_calib() -> Calib {
Calib {
f_m: 1.0,
f_dp: 1.0,
f_ua: 1.0,
f_power: 1.0,
f_etav: 1.0,
z_flow: 1.0,
z_flow_eco: 1.0,
z_dp: 1.0,
z_ua: 1.0,
z_power: 1.0,
z_etav: 1.0,
calibration_source: None,
}
}
@@ -123,9 +218,9 @@ fn analytical_solution(calib: &Calib) -> [f64; 8] {
let p3 = 350_000.0;
let h3 = 410_000.0;
let p0 = p3 + 1_000_000.0;
let h0 = h3 + 75_000.0 * calib.f_m * calib.f_power;
let p1 = p0 - 20_000.0 * calib.f_dp;
let h1 = h0 - 75_000.0 * calib.f_m * calib.f_power - 150_000.0 * calib.f_ua;
let h0 = h3 + 75_000.0 * calib.z_flow * calib.z_power;
let p1 = p0 - 20_000.0 * calib.z_dp;
let h1 = h0 - 75_000.0 * calib.z_flow * calib.z_power - 150_000.0 * calib.z_ua;
let p2 = p3;
let h2 = h1;
[p0, h0, p1, h1, p2, h2, p3, h3]
@@ -166,16 +261,36 @@ fn solve_calibrated_cycle(calib: &Calib) -> Vec<f64> {
system.add_edge(n_evap, n_comp).unwrap();
system.finalize().unwrap();
// CM1.2: state layout is now (ṁ, P, h) per edge (stride 3). Map the analytical
// (P, h) pairs onto the correct slots and seed each edge's mass flow.
let mut initial_state = vec![0.0; system.full_state_vector_len()];
for (i, edge_idx) in system.edge_indices().enumerate() {
let (m_idx, p_idx, h_idx) = system.edge_state_indices_full(edge_idx);
initial_state[m_idx] = DEFAULT_MASS_FLOW_SEED_KG_S;
initial_state[p_idx] = sol[2 * i];
initial_state[h_idx] = sol[2 * i + 1];
}
let mut config = NewtonConfig {
max_iterations: 100,
tolerance: 1e-8,
line_search: false,
use_numerical_jacobian: true,
initial_state: Some(sol.to_vec()),
initial_state: Some(initial_state),
..NewtonConfig::default()
};
config.solve(&mut system).unwrap().state
let result = config.solve(&mut system).unwrap().state;
// CM1.2: re-extract the (P, h) pairs per edge so downstream assertions keep
// the historical [p0, h0, p1, h1, ...] layout independent of the ṁ slots.
let mut ph = vec![0.0; 2 * system.edge_count()];
for (i, edge_idx) in system.edge_indices().enumerate() {
let (p_idx, h_idx) = system.edge_state_indices(edge_idx);
ph[2 * i] = result[p_idx];
ph[2 * i + 1] = result[h_idx];
}
ph
}
/// Baseline: all Calib = 1.0 → results match nominal analytical solution.
@@ -187,7 +302,14 @@ fn test_calibrated_cycle_nominal_baseline() {
for i in 0..8 {
let diff = (sv[i] - expected[i]).abs();
assert!(diff < 10.0, "sv[{}]: got {}, expected {}, diff {}", i, sv[i], expected[i], diff);
assert!(
diff < 10.0,
"sv[{}]: got {}, expected {}, diff {}",
i,
sv[i],
expected[i],
diff
);
}
// Energy balance check
@@ -203,7 +325,11 @@ fn test_calibrated_cycle_nominal_baseline() {
#[test]
fn test_calibrated_cycle_fua_increases_capacity() {
let nom = make_calib();
let cal = Calib { f_ua: 1.1, calibration_source: Some("synthetic-fua".into()), ..make_calib() };
let cal = Calib {
z_ua: 1.1,
calibration_source: Some("synthetic-fua".into()),
..make_calib()
};
let sv_nom = solve_calibrated_cycle(&nom);
let sv_cal = solve_calibrated_cycle(&cal);
@@ -223,8 +349,8 @@ fn test_calibrated_cycle_fua_increases_capacity() {
fn test_calibrated_cycle_fm_fpower_scales_compressor_work() {
let nom = make_calib();
let cal = Calib {
f_m: 1.05,
f_power: 1.03,
z_flow: 1.05,
z_power: 1.03,
calibration_source: Some("test-bench-2024-A".into()),
..make_calib()
};
@@ -248,7 +374,7 @@ fn test_calibrated_cycle_fm_fpower_scales_compressor_work() {
fn test_calibrated_cycle_fdp_scales_pressure_drop() {
let nom = make_calib();
let cal = Calib {
f_dp: 1.5,
z_dp: 1.5,
calibration_source: Some("dp-test-synthetic".into()),
..make_calib()
};
@@ -283,7 +409,7 @@ fn test_calibrated_cycle_with_calibration_source_metadata() {
calib.calibration_source.as_deref(),
Some("manufacturer-test-report-2024-TR-001")
);
assert_eq!(calib.f_ua, 1.1);
assert_eq!(calib.z_ua, 1.1);
let sv = solve_calibrated_cycle(&calib);