Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
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>
This commit is contained in:
267
crates/solver/tests/convergence_reason.rs
Normal file
267
crates/solver/tests/convergence_reason.rs
Normal file
@@ -0,0 +1,267 @@
|
||||
//! 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 { .. })));
|
||||
}
|
||||
Reference in New Issue
Block a user