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,18 +1,17 @@
use entropyk_components::port::{Connected, FluidId, Port};
/// Test d'intégration : boucle réfrigération simple R134a en Rust natif.
///
/// Ce test valide que le solveur Newton converge sur un cycle 4 composants
/// en utilisant des mock components algébriques linéaires dont les équations
/// sont mathématiquement cohérentes (ferment la boucle).
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Enthalpy, MassFlow, Pressure};
use entropyk_solver::{
solver::{NewtonConfig, Solver},
system::System,
system::{System, DEFAULT_MASS_FLOW_SEED_KG_S},
};
use entropyk_components::port::{Connected, FluidId, Port};
// Type alias: Port<Connected> ≡ ConnectedPort
type CP = Port<Connected>;
@@ -20,72 +19,158 @@ type CP = Port<Connected>;
// ─── Mock compresseur ─────────────────────────────────────────────────────────
// r[0] = p_disc - (p_suc + 1 MPa)
// r[1] = h_disc - (h_suc + 75 kJ/kg)
struct MockCompressor { port_suc: CP, port_disc: CP }
struct MockCompressor {
port_suc: CP,
port_disc: CP,
}
impl Component for MockCompressor {
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
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() + 75_000.0);
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
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() + 75_000.0);
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),
])
}
}
// ─── Mock condenseur ──────────────────────────────────────────────────────────
// r[0] = p_out - p_in
// r[1] = h_out - (h_in - 225 kJ/kg)
struct MockCondenser { port_in: CP, port_out: CP }
struct MockCondenser {
port_in: CP,
port_out: CP,
}
impl Component for MockCondenser {
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
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() - 225_000.0);
r[1] = self.port_out.enthalpy().to_joules_per_kg()
- (self.port_in.enthalpy().to_joules_per_kg() - 225_000.0);
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),
])
}
}
// ─── Mock détendeur ───────────────────────────────────────────────────────────
// r[0] = p_out - (p_in - 1 MPa)
// r[1] = h_out - h_in
struct MockValve { port_in: CP, port_out: CP }
struct MockValve {
port_in: CP,
port_out: CP,
}
impl Component for MockValve {
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - 1_000_000.0);
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> {
r[0] = self.port_out.pressure().to_pascals()
- (self.port_in.pressure().to_pascals() - 1_000_000.0);
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),
])
}
}
// ─── Mock évaporateur ─────────────────────────────────────────────────────────
// r[0] = p_out - p_in
// r[1] = h_out - (h_in + 150 kJ/kg)
struct MockEvaporator { port_in: CP, port_out: CP }
struct MockEvaporator {
port_in: CP,
port_out: CP,
}
impl Component for MockEvaporator {
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
fn compute_residuals(
&self,
_s: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
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() + 150_000.0);
r[1] = self.port_out.enthalpy().to_joules_per_kg()
- (self.port_in.enthalpy().to_joules_per_kg() + 150_000.0);
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),
])
}
}
@@ -95,11 +180,13 @@ 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
}
@@ -123,8 +210,8 @@ fn test_simple_refrigeration_loop_rust() {
// h2 = 260, p2 = 350 kPa
// h3 = 410, p3 = 350 kPa
let p_lp = 350_000.0_f64; // Pa
let p_hp = 1_350_000.0_f64; // Pa = p_lp + 1 MPa
let p_lp = 350_000.0_f64; // Pa
let p_hp = 1_350_000.0_f64; // Pa = p_lp + 1 MPa
// Les 4 bords (edge) du cycle :
// edge0 : comp → cond
@@ -132,19 +219,19 @@ fn test_simple_refrigeration_loop_rust() {
// edge2 : valve → evap
// edge3 : evap → comp
let comp = Box::new(MockCompressor {
port_suc: port(p_lp, 410_000.0),
port_suc: port(p_lp, 410_000.0),
port_disc: port(p_hp, 485_000.0),
});
let cond = Box::new(MockCondenser {
port_in: port(p_hp, 485_000.0),
port_in: port(p_hp, 485_000.0),
port_out: port(p_hp, 260_000.0),
});
let valv = Box::new(MockValve {
port_in: port(p_hp, 260_000.0),
port_in: port(p_hp, 260_000.0),
port_out: port(p_lp, 260_000.0),
});
let evap = Box::new(MockEvaporator {
port_in: port(p_lp, 260_000.0),
port_in: port(p_lp, 260_000.0),
port_out: port(p_lp, 410_000.0),
});
@@ -164,12 +251,16 @@ fn test_simple_refrigeration_loop_rust() {
let n_vars = system.full_state_vector_len();
println!("Variables d'état : {}", n_vars);
// État initial = solution analytique exacte → résidus = 0 → converge 1 itération
// État initial = solution analytique exacte → résidus = 0 → converge 1 itération.
// CM1.4 layout: 1 ṁ partagé (branche série unique) + (P, h) par arête.
// state = [ṁ, P₀, h₀, P₁, h₁, P₂, h₂, P₃, h₃] (9 éléments)
let m = DEFAULT_MASS_FLOW_SEED_KG_S;
let initial_state = vec![
p_hp, 485_000.0, // edge0 comp→cond
p_hp, 260_000.0, // edge1 cond→valve
p_lp, 260_000.0, // edge2 valve→evap
p_lp, 410_000.0, // edge3 evap→comp
m, // ṁ partagé (branche 0)
p_hp, 485_000.0, // edge0 comp→cond : P, h
p_hp, 260_000.0, // edge1 cond→valve : P, h
p_lp, 260_000.0, // edge2 valve→evap : P, h
p_lp, 410_000.0, // edge3 evap→comp : P, h
];
let mut config = NewtonConfig {
@@ -189,12 +280,32 @@ fn test_simple_refrigeration_loop_rust() {
match &result {
Ok(converged) => {
println!("✅ Convergé en {} itérations ({:?})", converged.iterations, elapsed);
println!(
"✅ Convergé en {} itérations ({:?})",
converged.iterations, elapsed
);
let sv = &converged.state;
println!(" comp→cond : P={:.2} bar, h={:.1} kJ/kg", sv[0]/1e5, sv[1]/1e3);
println!(" cond→valve : P={:.2} bar, h={:.1} kJ/kg", sv[2]/1e5, sv[3]/1e3);
println!(" valve→evap : P={:.2} bar, h={:.1} kJ/kg", sv[4]/1e5, sv[5]/1e3);
println!(" evap→comp : P={:.2} bar, h={:.1} kJ/kg", sv[6]/1e5, sv[7]/1e3);
// CM1.4 layout: sv[0]=ṁ, then (P,h) per edge at stride 2.
println!(
" comp→cond : P={:.2} bar, h={:.1} kJ/kg",
sv[1] / 1e5,
sv[2] / 1e3
);
println!(
" cond→valve : P={:.2} bar, h={:.1} kJ/kg",
sv[3] / 1e5,
sv[4] / 1e3
);
println!(
" valve→evap : P={:.2} bar, h={:.1} kJ/kg",
sv[5] / 1e5,
sv[6] / 1e3
);
println!(
" evap→comp : P={:.2} bar, h={:.1} kJ/kg",
sv[7] / 1e5,
sv[8] / 1e3
);
}
Err(e) => {
panic!("❌ Solveur échoué : {:?}", e);
@@ -204,3 +315,193 @@ fn test_simple_refrigeration_loop_rust() {
assert!(elapsed.as_millis() < 5000, "Doit converger en < 5 secondes");
assert!(result.is_ok(), "Solveur doit converger");
}
// ─── T6 — Topology presolve assertions ───────────────────────────────────────
/// AC #3, #5: For a pure 4-edge series cycle, the topology presolve must:
/// - Produce state_vector_len = 9 (1 ṁ branch + 4×2 P,h) instead of 12 (old 4×3).
/// - Assign the same ṁ state index to all 4 edges (shared branch).
/// - Keep the system square: n_branches inferred as state_len - 2×edge_count = 1.
#[test]
fn test_topology_presolve_state_layout() {
let p_lp = 350_000.0_f64;
let p_hp = 1_350_000.0_f64;
let comp = Box::new(MockCompressor {
port_suc: port(p_lp, 410_000.0),
port_disc: port(p_hp, 485_000.0),
});
let cond = Box::new(MockCondenser {
port_in: port(p_hp, 485_000.0),
port_out: port(p_hp, 260_000.0),
});
let valv = Box::new(MockValve {
port_in: port(p_hp, 260_000.0),
port_out: port(p_lp, 260_000.0),
});
let evap = Box::new(MockEvaporator {
port_in: port(p_lp, 260_000.0),
port_out: port(p_lp, 410_000.0),
});
let mut system = System::new();
let n_comp = system.add_component(comp);
let n_cond = system.add_component(cond);
let n_valv = system.add_component(valv);
let n_evap = system.add_component(evap);
let e0 = system.add_edge(n_comp, n_cond).unwrap();
let e1 = system.add_edge(n_cond, n_valv).unwrap();
let e2 = system.add_edge(n_valv, n_evap).unwrap();
let e3 = system.add_edge(n_evap, n_comp).unwrap();
system.finalize().unwrap();
// AC #3: CM1.4 state layout must be |B| + 2|E| = 1 + 8 = 9 (not 12).
let state_len = system.state_vector_len();
assert_eq!(
state_len, 9,
"CM1.4 state must be 1 branch + 4×2 P,h = 9, got {}",
state_len
);
// AC #3: Branch count inference — all branches used exactly 1 ṁ slot.
let edge_count = 4;
let n_branches_inferred = state_len - 2 * edge_count;
assert_eq!(
n_branches_inferred, 1,
"pure series cycle must have exactly 1 branch, inferred {}",
n_branches_inferred
);
// AC #3: All 4 edges share the same ṁ state index.
let m_idx: Vec<usize> = [e0, e1, e2, e3]
.iter()
.map(|&e| system.edge_state_indices_full(e).0)
.collect();
let first_m = m_idx[0];
assert!(
m_idx.iter().all(|&m| m == first_m),
"all edges in a series branch must share the same ṁ index; got {:?}",
m_idx
);
assert_eq!(first_m, 0, "shared ṁ index must be 0 (first slot)");
}
/// AC #5: A two-circuit system (2 independent series cycles) must have
/// 2 independent branch ṁ unknowns and state_vector_len = 2×(1 + 2×4) = 18.
#[test]
fn test_topology_presolve_two_independent_circuits() {
use entropyk_solver::CircuitId;
let p_lp = 350_000.0_f64;
let p_hp = 1_350_000.0_f64;
let mut system = System::new();
// ── Circuit 0 ──
let c0_comp = system
.add_component_to_circuit(
Box::new(MockCompressor {
port_suc: port(p_lp, 410_000.0),
port_disc: port(p_hp, 485_000.0),
}),
CircuitId::ZERO,
)
.unwrap();
let c0_cond = system
.add_component_to_circuit(
Box::new(MockCondenser {
port_in: port(p_hp, 485_000.0),
port_out: port(p_hp, 260_000.0),
}),
CircuitId::ZERO,
)
.unwrap();
let c0_valv = system
.add_component_to_circuit(
Box::new(MockValve {
port_in: port(p_hp, 260_000.0),
port_out: port(p_lp, 260_000.0),
}),
CircuitId::ZERO,
)
.unwrap();
let c0_evap = system
.add_component_to_circuit(
Box::new(MockEvaporator {
port_in: port(p_lp, 260_000.0),
port_out: port(p_lp, 410_000.0),
}),
CircuitId::ZERO,
)
.unwrap();
system.add_edge(c0_comp, c0_cond).unwrap();
system.add_edge(c0_cond, c0_valv).unwrap();
system.add_edge(c0_valv, c0_evap).unwrap();
system.add_edge(c0_evap, c0_comp).unwrap();
// ── Circuit 1 ──
let c1 = CircuitId::from_number(1);
let c1_comp = system
.add_component_to_circuit(
Box::new(MockCompressor {
port_suc: port(p_lp, 410_000.0),
port_disc: port(p_hp, 485_000.0),
}),
c1,
)
.unwrap();
let c1_cond = system
.add_component_to_circuit(
Box::new(MockCondenser {
port_in: port(p_hp, 485_000.0),
port_out: port(p_hp, 260_000.0),
}),
c1,
)
.unwrap();
let c1_valv = system
.add_component_to_circuit(
Box::new(MockValve {
port_in: port(p_hp, 260_000.0),
port_out: port(p_lp, 260_000.0),
}),
c1,
)
.unwrap();
let c1_evap = system
.add_component_to_circuit(
Box::new(MockEvaporator {
port_in: port(p_lp, 260_000.0),
port_out: port(p_lp, 410_000.0),
}),
c1,
)
.unwrap();
system.add_edge(c1_comp, c1_cond).unwrap();
system.add_edge(c1_cond, c1_valv).unwrap();
system.add_edge(c1_valv, c1_evap).unwrap();
system.add_edge(c1_evap, c1_comp).unwrap();
system.finalize().unwrap();
// 2 circuits × (1 branch + 4×2 P,h) = 2 × 9 = 18 state variables.
let state_len = system.state_vector_len();
assert_eq!(
state_len, 18,
"two independent 4-edge cycles = 2 branches + 8×2 P,h = 18, got {}",
state_len
);
// Inferred branch count = 18 - 2*8 = 2.
let n_branches_inferred = state_len - 2 * 8;
assert_eq!(
n_branches_inferred, 2,
"two independent cycles must have 2 branches, inferred {}",
n_branches_inferred
);
}