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:
296
crates/solver/tests/homotopy_continuation.rs
Normal file
296
crates/solver/tests/homotopy_continuation.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user