Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
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:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View 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 { .. })));
}