Files
Entropyk/crates/solver/tests/convergence_reason.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:35:31 +02:00

268 lines
9.7 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 Story 1.2: Core Crate & Typed Convergence Taxonomy.
//!
//! Covers:
//! - AC #1: non-converged terminations are reported as `ConvergenceReason`
//! outcomes (via `SolverError::convergence_reason` and `Solver::solve_outcome`),
//! while hard errors (invalid system, validation) remain `Err`.
//! - AC #3: existing `SolverError` behavior (incl. `WithDiagnostics`) is
//! unchanged — the taxonomy is purely additive.
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice};
use entropyk_solver::solver::{NewtonConfig, Solver, SolverError};
use entropyk_solver::system::{System, DEFAULT_MASS_FLOW_SEED_KG_S, MIN_SOLVER_PRESSURE_PA};
use entropyk_solver::{ConvergenceDiagnostics, ConvergenceReason, SolveOutcome};
// ─────────────────────────────────────────────────────────────────────────────
// Mock components (same fixture pattern as fallback_solver.rs)
// ─────────────────────────────────────────────────────────────────────────────
/// A well-conditioned linear system r = A·x b: converges in one Newton step.
struct LinearSystem {
a: Vec<Vec<f64>>,
b: Vec<f64>,
n: usize,
}
impl LinearSystem {
fn well_conditioned() -> Self {
let (p, h) = (MIN_SOLVER_PRESSURE_PA, MIN_SOLVER_PRESSURE_PA);
Self {
a: vec![vec![2.0, 1.0], vec![1.0, 2.0]],
b: vec![2.0 * p + h, p + 2.0 * h],
n: 2,
}
}
}
impl Component for LinearSystem {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for (i, residual) in residuals.iter_mut().enumerate().take(self.n) {
let mut ax_i = 0.0;
for j in 0..self.n {
ax_i += self.a[i][j] * state[1 + j];
}
*residual = ax_i - self.b[i];
}
residuals[self.n] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
for i in 0..self.n {
for j in 0..self.n {
jacobian.add_entry(i, 1 + j, self.a[i][j]);
}
}
jacobian.add_entry(self.n, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
self.n + 1
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// A mildly non-linear system ((x p₀)² = 1) parked at the pressure floor,
/// needing several Newton steps from an offset guess — deterministic
/// `MaxIters` generator when the iteration budget is 1.
struct QuadraticSystem;
impl Component for QuadraticSystem {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// r0 = (x p₀)² 1 (root at p₀ + 1, on the clip floor like the
// established LinearSystem fixture; residual stays O(1) so neither
// the divergence threshold nor pressure clipping interferes).
let dx = state[1] - MIN_SOLVER_PRESSURE_PA;
residuals[0] = dx * dx - 1.0;
// r1 pins the second unknown.
residuals[1] = state[2] - MIN_SOLVER_PRESSURE_PA;
// Mass-flow row pins ṁ at the seed value.
residuals[2] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S;
Ok(())
}
fn jacobian_entries(
&self,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 1, 2.0 * (state[1] - MIN_SOLVER_PRESSURE_PA));
jacobian.add_entry(1, 2, 1.0);
jacobian.add_entry(2, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
3
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
fn create_test_system(component: Box<dyn Component>) -> System {
let mut system = System::new();
let n0 = system.add_component(component);
system.add_edge(n0, n0).unwrap();
system.finalize().unwrap();
system
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: SolverError classification into ConvergenceReason
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn non_convergence_maps_to_max_iters() {
let err = SolverError::NonConvergence {
iterations: 42,
final_residual: 1.0e-3,
};
assert_eq!(err.convergence_reason(), Some(ConvergenceReason::MaxIters));
}
#[test]
fn timeout_maps_to_timed_out() {
let err = SolverError::Timeout { timeout_ms: 500 };
assert_eq!(err.convergence_reason(), Some(ConvergenceReason::TimedOut));
}
#[test]
fn divergence_maps_to_stalled() {
let err = SolverError::Divergence {
reason: "residual growing".to_string(),
};
assert_eq!(err.convergence_reason(), Some(ConvergenceReason::Stalled));
}
#[test]
fn with_diagnostics_delegates_to_inner_error() {
let base = SolverError::NonConvergence {
iterations: 10,
final_residual: 1.0e-4,
};
let wrapped = SolverError::WithDiagnostics {
error: Box::new(base),
diagnostics: Box::new(ConvergenceDiagnostics::new()),
};
assert_eq!(
wrapped.convergence_reason(),
Some(ConvergenceReason::MaxIters)
);
}
#[test]
fn hard_errors_map_to_none() {
let invalid = SolverError::InvalidSystem {
message: "empty system".to_string(),
};
assert_eq!(invalid.convergence_reason(), None);
let validation = SolverError::Validation {
mass_error: 1.0,
energy_error: 2.0,
};
assert_eq!(validation.convergence_reason(), None);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: solve_outcome reports outcomes as data
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn solve_outcome_reports_converged_as_data() {
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let mut solver = NewtonConfig::default();
let outcome: SolveOutcome = solver
.solve_outcome(&mut system)
.expect("converged solve must not be a hard error");
assert_eq!(outcome.reason, ConvergenceReason::Converged);
assert!(outcome.is_converged());
assert!(outcome.final_residual < 1e-6);
assert!(outcome.state.is_some());
}
#[test]
fn solve_outcome_reports_max_iters_as_data_not_err() {
let mut system = create_test_system(Box::new(QuadraticSystem));
let mut solver = NewtonConfig {
max_iterations: 1,
tolerance: 1e-9,
initial_state: Some(vec![
DEFAULT_MASS_FLOW_SEED_KG_S,
MIN_SOLVER_PRESSURE_PA + 10.0,
MIN_SOLVER_PRESSURE_PA,
]),
..NewtonConfig::default()
};
let outcome = solver
.solve_outcome(&mut system)
.expect("MaxIters termination must be an outcome, not a hard Err");
assert_eq!(outcome.reason, ConvergenceReason::MaxIters);
assert!(!outcome.is_converged());
assert!(outcome.iterations >= 1);
assert!(outcome.final_residual.is_finite());
assert!(outcome.state.is_none());
}
#[test]
fn solve_outcome_keeps_hard_errors_as_err() {
// Empty system: InvalidSystem is a hard error and must remain `Err`.
let mut system = System::new();
system.finalize().unwrap();
let mut solver = NewtonConfig::default();
let result = solver.solve_outcome(&mut system);
match result {
Err(SolverError::InvalidSystem { .. }) => {}
other => panic!("expected Err(InvalidSystem), got {:?}", other),
}
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: legacy behavior untouched
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn legacy_solve_still_returns_err_on_non_convergence() {
// The legacy `solve` contract is unchanged: NonConvergence stays an `Err`.
// Only the additive `solve_outcome` reports outcomes as data.
let mut system = create_test_system(Box::new(QuadraticSystem));
let mut solver = NewtonConfig {
max_iterations: 1,
tolerance: 1e-9,
initial_state: Some(vec![
DEFAULT_MASS_FLOW_SEED_KG_S,
MIN_SOLVER_PRESSURE_PA + 10.0,
MIN_SOLVER_PRESSURE_PA,
]),
..NewtonConfig::default()
};
let result = solver.solve(&mut system);
assert!(matches!(result, Err(SolverError::NonConvergence { .. })));
}