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>
508 lines
16 KiB
Rust
508 lines
16 KiB
Rust
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, DEFAULT_MASS_FLOW_SEED_KG_S},
|
||
};
|
||
|
||
// Type alias: Port<Connected> ≡ ConnectedPort
|
||
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,
|
||
}
|
||
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);
|
||
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 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),
|
||
])
|
||
}
|
||
}
|
||
|
||
// ─── 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,
|
||
}
|
||
impl Component for MockCondenser {
|
||
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);
|
||
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 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),
|
||
])
|
||
}
|
||
}
|
||
|
||
// ─── 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,
|
||
}
|
||
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();
|
||
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 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),
|
||
])
|
||
}
|
||
}
|
||
|
||
// ─── 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,
|
||
}
|
||
impl Component for MockEvaporator {
|
||
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);
|
||
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 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),
|
||
])
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
fn port(p_pa: f64, h_j_kg: f64) -> CP {
|
||
let (connected, _) = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_pascals(p_pa),
|
||
Enthalpy::from_joules_per_kg(h_j_kg),
|
||
)
|
||
.connect(Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_pascals(p_pa),
|
||
Enthalpy::from_joules_per_kg(h_j_kg),
|
||
))
|
||
.unwrap();
|
||
connected
|
||
}
|
||
|
||
// ─── Test ─────────────────────────────────────────────────────────────────────
|
||
#[test]
|
||
fn test_simple_refrigeration_loop_rust() {
|
||
// Les équations :
|
||
// Comp : p0 = p3 + 1 MPa ; h0 = h3 + 75 kJ/kg
|
||
// Cond : p1 = p0 ; h1 = h0 - 225 kJ/kg
|
||
// Valve : p2 = p1 - 1 MPa ; h2 = h1
|
||
// Evap : p3 = p2 ; h3 = h2 + 150 kJ/kg
|
||
//
|
||
// Bilan enthalpique en boucle : 75 - 225 + 150 = 0 → fermé ✓
|
||
// Bilan pressionnel en boucle : +1 - 0 - 1 - 0 = 0 → fermé ✓
|
||
//
|
||
// Solution analytique (8 inconnues, 8 équations → infinité de solutions
|
||
// dépendant du point de référence, mais le solveur en trouve une) :
|
||
// En posant h3 = 410 kJ/kg, p3 = 350 kPa :
|
||
// h0 = 485, p0 = 1.35 MPa
|
||
// h1 = 260, p1 = 1.35 MPa
|
||
// 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
|
||
|
||
// Les 4 bords (edge) du cycle :
|
||
// edge0 : comp → cond
|
||
// edge1 : cond → valve
|
||
// edge2 : valve → evap
|
||
// edge3 : evap → comp
|
||
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);
|
||
|
||
system.add_edge(n_comp, n_cond).unwrap();
|
||
system.add_edge(n_cond, n_valv).unwrap();
|
||
system.add_edge(n_valv, n_evap).unwrap();
|
||
system.add_edge(n_evap, n_comp).unwrap();
|
||
|
||
system.finalize().unwrap();
|
||
|
||
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.
|
||
// 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![
|
||
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 {
|
||
max_iterations: 50,
|
||
tolerance: 1e-6,
|
||
line_search: false,
|
||
use_numerical_jacobian: true, // analytique vide → numérique
|
||
initial_state: Some(initial_state),
|
||
..NewtonConfig::default()
|
||
};
|
||
|
||
let t0 = std::time::Instant::now();
|
||
let result = config.solve(&mut system);
|
||
let elapsed = t0.elapsed();
|
||
|
||
println!("Durée : {:?}", elapsed);
|
||
|
||
match &result {
|
||
Ok(converged) => {
|
||
println!(
|
||
"✅ Convergé en {} itérations ({:?})",
|
||
converged.iterations, elapsed
|
||
);
|
||
let sv = &converged.state;
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
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
|
||
);
|
||
}
|