Files
Entropyk/crates/solver/tests/failure_diagnostics.rs
sepehr 3358b74342 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>
2026-07-17 22:46:46 +02:00

416 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"
);
}