//! 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; 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, 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, h_idx: Arc, 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, 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`. 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" ); }