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

@@ -0,0 +1,296 @@
//! Integration test for the Newton-homotopy continuation solver.
//!
//! Builds the same 4-component R134a refrigeration loop used by the Newton
//! integration test (`refrigeration_cycle_integration.rs`) and solves it with
//! [`HomotopyConfig`] instead of `NewtonConfig`. The purpose is to prove the
//! homotopy strategy integrates end-to-end with the real edge-based [`System`]
//! machinery (stride-3 `(ṁ, P, h)` state, finalize, mass-flow closures) and
//! returns a converged result.
//!
//! NOTE on the fixture: the mock components return `&[]` from `get_ports()`, so
//! the `System` cannot wire edges to their ports. Their residuals are therefore
//! read from the construction-time port values (set to the analytic solution)
//! and are independent of the live state vector. This mirrors the existing
//! Newton integration test, which for the same reason only asserts convergence
//! rather than specific state values. The numerical behaviour of the homotopy
//! continuation (λ-stepping, residual blending, restart-on-failure) is covered
//! by the unit tests in `strategies::homotopy`.
use entropyk_components::port::{Connected, FluidId, Port};
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Enthalpy, MassFlow, Pressure};
use entropyk_solver::{
solver::{Solver, SolverError},
strategies::HomotopyConfig,
system::{System, DEFAULT_MASS_FLOW_SEED_KG_S},
};
type CP = Port<Connected>;
// 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),
])
}
}
// 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),
])
}
}
// 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),
])
}
}
// 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),
])
}
}
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
}
fn build_loop() -> System {
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);
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();
system
}
/// `HomotopyConfig` drives the real edge-based System machinery to a converged
/// result, just like `NewtonConfig` does on the same loop.
#[test]
fn test_homotopy_solves_refrigeration_loop() {
let mut system = build_loop();
let p_lp = 350_000.0_f64;
let p_hp = 1_350_000.0_f64;
let m = DEFAULT_MASS_FLOW_SEED_KG_S;
// CM1.4 layout: 1 shared ṁ (single series branch) + (P, h) per edge.
// state = [ṁ, P₀, h₀, P₁, h₁, P₂, h₂, P₃, h₃] (9 elements)
let initial_state = vec![
m, // ṁ shared (branch 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 solver = HomotopyConfig {
use_numerical_jacobian: true, // mock analytic Jacobian is empty
initial_state: Some(initial_state),
..HomotopyConfig::default()
};
let t0 = std::time::Instant::now();
let result = solver
.solve(&mut system)
.expect("homotopy should converge on the refrigeration loop");
let elapsed = t0.elapsed();
assert!(
result.final_residual < 1e-6,
"final residual too large: {:.3e}",
result.final_residual
);
assert!(elapsed.as_millis() < 5000, "should converge in < 5 s");
}
/// A caller-supplied `initial_state` whose length does not match the system
/// state vector must be rejected with `InvalidSystem` rather than silently
/// substituted by an all-zeros guess (which would hide the caller's bug).
#[test]
fn test_homotopy_rejects_mismatched_initial_state_length() {
let mut system = build_loop();
let n_state = system.full_state_vector_len();
assert!(n_state > 0, "loop should have state variables");
let mut solver = HomotopyConfig {
use_numerical_jacobian: true,
initial_state: Some(vec![0.0; n_state + 1]), // deliberately too long
..HomotopyConfig::default()
};
match solver.solve(&mut system) {
Err(SolverError::InvalidSystem { message }) => {
assert!(
message.contains("initial_state length"),
"unexpected message: {message}"
);
}
other => panic!("expected InvalidSystem for length mismatch, got {other:?}"),
}
}