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>
416 lines
14 KiB
Rust
416 lines
14 KiB
Rust
//! Integration tests for failure diagnostics propagation.
|
||
//!
|
||
//! Verifies that solver failures carry `ConvergenceDiagnostics` including
|
||
//! dominant residual index and value, satisfying spec-cli-failure-diagnostics.md AC1.
|
||
|
||
use std::sync::{
|
||
atomic::{AtomicUsize, Ordering},
|
||
Arc,
|
||
};
|
||
|
||
use entropyk_components::{
|
||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||
};
|
||
use entropyk_core::MassFlow;
|
||
use entropyk_solver::{
|
||
solver::{NewtonConfig, Solver, SolverError, VerboseConfig},
|
||
system::System,
|
||
CircuitId, PicardConfig,
|
||
};
|
||
|
||
// ── Port-reading mock (constant residuals) ──────────────────────────────────
|
||
//
|
||
// Used for Picard and no-verbose tests where state-dependent residuals are not
|
||
// needed. The residuals are constant (don't depend on the state vector) but
|
||
// non-zero — the solver iterates until max_iterations without converging.
|
||
//
|
||
// For Picard this is fine; for Newton this produces a singular Jacobian (zero
|
||
// finite-difference columns), so Newton fails at iteration 1 without recording
|
||
// any iteration diagnostics.
|
||
|
||
use entropyk_components::port::{Connected, FluidId, Port};
|
||
use entropyk_core::{Enthalpy, Pressure};
|
||
|
||
type CP = Port<Connected>;
|
||
|
||
struct PortMock {
|
||
port_in: CP,
|
||
port_out: CP,
|
||
dp_pa: f64,
|
||
dh_jkg: f64,
|
||
/// Number of equations this mock reports to the solver.
|
||
/// CM1.4: in a 2-edge series cycle, state_len = 1 branch + 4 P,h = 5.
|
||
/// Use 3 for the "compressor" (pressure reference) and 2 for the "condenser"
|
||
/// to reach 3+2=5 total equations, matching state_len.
|
||
n_eqs: usize,
|
||
}
|
||
|
||
impl Component for PortMock {
|
||
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() + self.dp_pa);
|
||
r[1] = self.port_out.enthalpy().to_joules_per_kg()
|
||
- (self.port_in.enthalpy().to_joules_per_kg() + self.dh_jkg);
|
||
if self.n_eqs >= 3 {
|
||
r[2] = 0.0; // mass balance trivially satisfied (mock)
|
||
}
|
||
Ok(())
|
||
}
|
||
fn jacobian_entries(
|
||
&self,
|
||
_s: &StateSlice,
|
||
_j: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
fn n_equations(&self) -> usize {
|
||
self.n_eqs
|
||
}
|
||
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 make_cp(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),
|
||
))
|
||
.expect("port connect ok");
|
||
connected
|
||
}
|
||
|
||
/// Build a 2-component closed loop whose residuals are constant (port-based).
|
||
/// The loop is physically inconsistent: compressor imposes +1 MPa pressure rise
|
||
/// while condenser imposes 0 pressure drop around the same loop, so the system
|
||
/// has no solution. Suitable for Picard tests (which don't need the Jacobian).
|
||
fn build_port_loop() -> System {
|
||
let p_high = 1_200_000.0_f64;
|
||
let p_low = 300_000.0_f64;
|
||
let h_high = 450_000.0_f64;
|
||
let h_low = 250_000.0_f64;
|
||
|
||
let mut system = System::new();
|
||
let cid = CircuitId(0);
|
||
|
||
// CM1.4: 2-edge series cycle → 1 branch + 4 P,h = 5 state unknowns.
|
||
// Compressor acts as the pressure-reference node (3 equations); condenser
|
||
// is a pure series-branch component (2 equations). Total: 3+2=5 = balanced.
|
||
let comp = PortMock {
|
||
port_in: make_cp(p_low, h_low),
|
||
port_out: make_cp(p_high, h_high),
|
||
dp_pa: 900_000.0,
|
||
dh_jkg: 200_000.0,
|
||
n_eqs: 3,
|
||
};
|
||
let cond = PortMock {
|
||
port_in: make_cp(p_high, h_high),
|
||
port_out: make_cp(p_low, h_low),
|
||
dp_pa: 0.0,
|
||
dh_jkg: -200_000.0,
|
||
n_eqs: 2,
|
||
};
|
||
|
||
let n0 = system
|
||
.add_component_to_circuit(Box::new(comp), cid)
|
||
.unwrap();
|
||
let n1 = system
|
||
.add_component_to_circuit(Box::new(cond), cid)
|
||
.unwrap();
|
||
system.add_edge(n0, n1).unwrap();
|
||
system.add_edge(n1, n0).unwrap();
|
||
system.finalize().unwrap();
|
||
system
|
||
}
|
||
|
||
// ── State-reading mock with nonlinear residuals ─────────────────────────────
|
||
//
|
||
// Constrains (P, h) of an edge using a weakly nonlinear equation:
|
||
// r[0] = state[pi] + C * state[pi]^3 - p_target
|
||
// r[1] = state[hi] + C * state[hi]^3 - h_target
|
||
//
|
||
// The cubic perturbation (C = 1e-10) is small enough to leave the Jacobian
|
||
// well-conditioned but large enough to prevent Newton from reaching residual
|
||
// zero in one step. For p_target = 1000:
|
||
//
|
||
// After step 1 (from state=0): state[pi] ≈ 1000,
|
||
// residual ≈ C * target^3 = 1e-10 * 1e9 = 0.1 >> 1e-100
|
||
// After 4-5 iterations: residual ≈ machine epsilon (1e-16) >> 1e-100
|
||
//
|
||
// Newton never meets tolerance = 1e-100, so NonConvergence is returned with a
|
||
// full iteration history and a non-zero dominant residual — satisfying AC1.
|
||
|
||
// C_NL = 1e-3: strong enough cubic perturbation so Newton converges slowly
|
||
// (residual ~7000 after 5 steps, >> 1e-100), but weak enough to avoid immediate
|
||
// divergence (each step reduces the residual monotonically toward x* ≈ 97 Pa).
|
||
const C_NL: f64 = 1e-3;
|
||
|
||
struct StateReadingMock {
|
||
p_idx: Arc<AtomicUsize>,
|
||
h_idx: Arc<AtomicUsize>,
|
||
p_target: f64,
|
||
h_target: f64,
|
||
}
|
||
|
||
impl Component for StateReadingMock {
|
||
fn compute_residuals(
|
||
&self,
|
||
state: &StateSlice,
|
||
r: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
let pi = self.p_idx.load(Ordering::Relaxed);
|
||
let hi = self.h_idx.load(Ordering::Relaxed);
|
||
r[0] = state[pi] + C_NL * state[pi].powi(3) - self.p_target;
|
||
r[1] = state[hi] + C_NL * state[hi].powi(3) - self.h_target;
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
state: &StateSlice,
|
||
j: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
let pi = self.p_idx.load(Ordering::Relaxed);
|
||
let hi = self.h_idx.load(Ordering::Relaxed);
|
||
j.add_entry(0, pi, 1.0 + 3.0 * C_NL * state[pi].powi(2));
|
||
j.add_entry(1, hi, 1.0 + 3.0 * C_NL * state[hi].powi(2));
|
||
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)])
|
||
}
|
||
}
|
||
|
||
/// Build a 2-component, 2-edge system with nonlinear, state-dependent residuals.
|
||
///
|
||
/// Each component constrains (P, h) of one edge through a mildly nonlinear
|
||
/// equation (cubic perturbation). The Jacobian is non-singular (diagonal,
|
||
/// values ≈ 1.0003). Newton takes real steps but cannot reach tolerance 1e-100
|
||
/// before `max_iterations` — residuals bottom out at machine epsilon (~1e-16)
|
||
/// which is still >> 1e-100.
|
||
///
|
||
/// State indices are injected post-finalization via `Arc<AtomicUsize>`.
|
||
fn build_state_reading_loop() -> System {
|
||
let p0 = Arc::new(AtomicUsize::new(0));
|
||
let h0 = Arc::new(AtomicUsize::new(0));
|
||
let p1 = Arc::new(AtomicUsize::new(0));
|
||
let h1 = Arc::new(AtomicUsize::new(0));
|
||
|
||
let comp = StateReadingMock {
|
||
p_idx: Arc::clone(&p0),
|
||
h_idx: Arc::clone(&h0),
|
||
p_target: 1000.0, // 1000 Pa — small but arbitrary for the test
|
||
h_target: 500.0,
|
||
};
|
||
let cond = StateReadingMock {
|
||
p_idx: Arc::clone(&p1),
|
||
h_idx: Arc::clone(&h1),
|
||
p_target: 800.0,
|
||
h_target: 300.0,
|
||
};
|
||
|
||
let mut system = System::new();
|
||
let cid = CircuitId(0);
|
||
let n0 = system
|
||
.add_component_to_circuit(Box::new(comp), cid)
|
||
.unwrap();
|
||
let n1 = system
|
||
.add_component_to_circuit(Box::new(cond), cid)
|
||
.unwrap();
|
||
let edge0 = system.add_edge(n0, n1).unwrap();
|
||
let edge1 = system.add_edge(n1, n0).unwrap();
|
||
system.finalize().unwrap();
|
||
|
||
// Inject real state indices now that finalization has assigned them.
|
||
let (p0_real, h0_real) = system.edge_state_indices(edge0);
|
||
let (p1_real, h1_real) = system.edge_state_indices(edge1);
|
||
p0.store(p0_real, Ordering::Relaxed);
|
||
h0.store(h0_real, Ordering::Relaxed);
|
||
p1.store(p1_real, Ordering::Relaxed);
|
||
h1.store(h1_real, Ordering::Relaxed);
|
||
|
||
system
|
||
}
|
||
|
||
// ── AC1: solver failure carries dominant residual diagnostics ─────────────────
|
||
|
||
/// Newton on a state-reading mock system with tolerance=1e-100:
|
||
/// - Jacobian is non-singular (permuted identity) → Newton takes real steps.
|
||
/// - After max_iterations=5, NonConvergence is returned.
|
||
/// - Error carries ConvergenceDiagnostics with non-empty iteration history.
|
||
/// - final_dominant_residual() returns Some with a positive value.
|
||
///
|
||
/// Validates AC1 of spec-cli-failure-diagnostics.md for the Newton solver.
|
||
#[test]
|
||
fn test_newton_failure_carries_dominant_residual_diagnostics() {
|
||
let mut system = build_state_reading_loop();
|
||
|
||
let verbose = VerboseConfig {
|
||
enabled: true,
|
||
log_residuals: true,
|
||
log_jacobian_condition: false,
|
||
log_solver_switches: false,
|
||
dump_final_state: false,
|
||
output_format: Default::default(),
|
||
};
|
||
|
||
let mut solver = NewtonConfig {
|
||
max_iterations: 5,
|
||
tolerance: 1e-100, // impossible — machine-epsilon residuals keep Newton spinning
|
||
verbose_config: verbose,
|
||
..NewtonConfig::default()
|
||
};
|
||
|
||
let result = solver.solve(&mut system);
|
||
assert!(
|
||
result.is_err(),
|
||
"Solver must fail to converge to tolerance 1e-100"
|
||
);
|
||
|
||
let err = result.unwrap_err();
|
||
|
||
// Base error must be an iterative failure (NonConvergence or Divergence),
|
||
// not a structural InvalidSystem error.
|
||
let is_iterative_failure = matches!(
|
||
err.base_error(),
|
||
SolverError::NonConvergence { .. } | SolverError::Divergence { .. }
|
||
);
|
||
assert!(
|
||
is_iterative_failure,
|
||
"Base error must be iterative failure, got: {:?}",
|
||
err.base_error()
|
||
);
|
||
|
||
// Diagnostics must be attached (verbose mode was enabled and iterations occurred).
|
||
let diag = err
|
||
.diagnostics()
|
||
.expect("SolverError must carry ConvergenceDiagnostics when verbose mode is enabled");
|
||
|
||
// At least one iteration must have been recorded.
|
||
assert!(
|
||
!diag.iteration_history.is_empty(),
|
||
"Diagnostics must contain at least one iteration record (got {})",
|
||
diag.iteration_history.len()
|
||
);
|
||
|
||
// final_residual must be positive (system never converged to 1e-100).
|
||
assert!(
|
||
diag.final_residual >= 0.0,
|
||
"final_residual must be non-negative, got {}",
|
||
diag.final_residual
|
||
);
|
||
|
||
// Dominant residual must be extractable from iteration history.
|
||
let (dom_index, dom_value) = diag
|
||
.final_dominant_residual()
|
||
.expect("final_dominant_residual must return Some when iteration_history is non-empty");
|
||
|
||
assert!(
|
||
dom_value >= 0.0,
|
||
"dominant residual value must be non-negative, got {}",
|
||
dom_value
|
||
);
|
||
|
||
// The dominant index must be a valid equation index.
|
||
// System: 2 components × 2 equations + 2 closure = 6 total equations.
|
||
assert!(
|
||
dom_index < 30,
|
||
"dominant residual index out of expected range: {}",
|
||
dom_index
|
||
);
|
||
}
|
||
|
||
/// Picard on a port-loop (constant residuals, non-zero) with tolerance=1e-100:
|
||
/// - Picard doesn't use a Jacobian → iterates regardless of Jacobian singularity.
|
||
/// - After max_iterations=3, NonConvergence is returned with non-empty history.
|
||
/// - final_dominant_residual() returns Some with a positive value.
|
||
///
|
||
/// Validates AC1 for the Picard solver (mirrors Newton AC1).
|
||
#[test]
|
||
fn test_picard_failure_carries_dominant_residual_diagnostics() {
|
||
let mut system = build_port_loop();
|
||
|
||
let verbose = VerboseConfig {
|
||
enabled: true,
|
||
log_residuals: true,
|
||
..VerboseConfig::default()
|
||
};
|
||
|
||
let mut solver = PicardConfig {
|
||
max_iterations: 3,
|
||
tolerance: 1e-12,
|
||
verbose_config: verbose,
|
||
..PicardConfig::default()
|
||
};
|
||
|
||
let result = solver.solve(&mut system);
|
||
assert!(result.is_err());
|
||
|
||
let err = result.unwrap_err();
|
||
|
||
let diag = err
|
||
.diagnostics()
|
||
.expect("Picard error must carry ConvergenceDiagnostics on iterative failure");
|
||
|
||
assert!(
|
||
!diag.iteration_history.is_empty(),
|
||
"Picard diagnostics must contain at least one iteration"
|
||
);
|
||
|
||
assert!(
|
||
diag.final_dominant_residual().is_some(),
|
||
"Picard diagnostics must expose dominant residual"
|
||
);
|
||
|
||
let (_, dom_value) = diag.final_dominant_residual().unwrap();
|
||
assert!(dom_value >= 0.0);
|
||
}
|
||
|
||
/// Without verbose mode, solver errors carry no diagnostics regardless of
|
||
/// failure type — verifying backward compatibility for callers that opt out
|
||
/// of verbose instrumentation.
|
||
#[test]
|
||
fn test_failure_without_verbose_carries_no_diagnostics() {
|
||
let mut system = build_port_loop();
|
||
|
||
let mut solver = NewtonConfig {
|
||
max_iterations: 2,
|
||
tolerance: 1e-12,
|
||
verbose_config: VerboseConfig::default(), // verbose disabled
|
||
..NewtonConfig::default()
|
||
};
|
||
|
||
let result = solver.solve(&mut system);
|
||
assert!(result.is_err());
|
||
|
||
let err = result.unwrap_err();
|
||
|
||
assert!(
|
||
err.diagnostics().is_none(),
|
||
"No diagnostics expected when verbose mode is disabled"
|
||
);
|
||
}
|