Snapshot WIP: Probe calibration path, faer LU backend, and BPHX phase-change duty.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Checkpoint incomplete calibration work (cond SDT green, evap SST failing) plus related solver/UI changes so the next pass can fix and extend safely. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,7 @@ repository = "https://github.com/entropyk/entropyk"
|
||||
entropyk-components = { path = "../components" }
|
||||
entropyk-core = { path = "../core" }
|
||||
entropyk-solver-core = { path = "../solver-core" }
|
||||
faer = "0.24"
|
||||
nalgebra = "0.33"
|
||||
petgraph = "0.6"
|
||||
thiserror = "1.0"
|
||||
@@ -29,6 +30,10 @@ criterion = "0.5"
|
||||
name = "lu_solve"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "lu_backends"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "residual_jacobian_assembly"
|
||||
harness = false
|
||||
|
||||
85
crates/solver/benches/lu_backends.rs
Normal file
85
crates/solver/benches/lu_backends.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! Story 1.5 comparative benchmark: nalgebra vs faer dense LU backends.
|
||||
//!
|
||||
//! Measures factorization (`set_linearisation`) and per-RHS solve
|
||||
//! (`solve_in_place`) on the mock-cycle Jacobian (9×9) and a synthetic 50×50
|
||||
//! dense Jacobian — the sizes Entropyk Newton actually sees.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use entropyk_solver::linear::NalgebraLuSolver;
|
||||
use entropyk_solver::linear_faer::FaerLuSolver;
|
||||
use entropyk_solver::LinearSolver;
|
||||
|
||||
mod common;
|
||||
|
||||
fn dense_entries(n: usize) -> Vec<(usize, usize, f64)> {
|
||||
let mut entries = Vec::with_capacity(n * n);
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let v = if i == j {
|
||||
4.0 + 0.01 * (i as f64)
|
||||
} else {
|
||||
1.0 / (1.0 + (i as f64 - j as f64).abs())
|
||||
};
|
||||
entries.push((i, j, v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn bench_pair(c: &mut Criterion, label: &str, entries: &[(usize, usize, f64)], n: usize) {
|
||||
let mut group = c.benchmark_group(label);
|
||||
let rhs: Vec<f64> = (0..n)
|
||||
.map(|i| (i as f64 * 0.7 - 1.3).sin() * 100.0)
|
||||
.collect();
|
||||
|
||||
let mut nal = NalgebraLuSolver::new();
|
||||
nal.set_problem(n, n).unwrap();
|
||||
let mut faer = FaerLuSolver::new();
|
||||
faer.set_problem(n, n).unwrap();
|
||||
|
||||
group.bench_function(BenchmarkId::new("factor", "nalgebra"), |b| {
|
||||
b.iter(|| {
|
||||
black_box(nal.set_linearisation(black_box(entries))).unwrap();
|
||||
});
|
||||
});
|
||||
group.bench_function(BenchmarkId::new("factor", "faer"), |b| {
|
||||
b.iter(|| {
|
||||
black_box(faer.set_linearisation(black_box(entries))).unwrap();
|
||||
});
|
||||
});
|
||||
|
||||
nal.set_linearisation(entries).unwrap();
|
||||
faer.set_linearisation(entries).unwrap();
|
||||
group.bench_function(BenchmarkId::new("solve", "nalgebra"), |b| {
|
||||
b.iter(|| {
|
||||
let mut rhs = rhs.clone();
|
||||
let _ = black_box(nal.solve_in_place(black_box(&mut rhs)));
|
||||
});
|
||||
});
|
||||
group.bench_function(BenchmarkId::new("solve", "faer"), |b| {
|
||||
b.iter(|| {
|
||||
let mut rhs = rhs.clone();
|
||||
let _ = black_box(faer.solve_in_place(black_box(&mut rhs)));
|
||||
});
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_lu_backends(c: &mut Criterion) {
|
||||
let (system, state) = common::build_mock_system();
|
||||
let jacobian = common::assemble_jacobian(&system, &state);
|
||||
let n = jacobian.nrows();
|
||||
let jm = jacobian.as_matrix();
|
||||
let entries: Vec<(usize, usize, f64)> = (0..n)
|
||||
.flat_map(|i| (0..n).map(move |j| (i, j, jm[(i, j)])))
|
||||
.collect();
|
||||
bench_pair(c, "lu_mock_9x9", &entries, n);
|
||||
|
||||
let entries50 = dense_entries(50);
|
||||
bench_pair(c, "lu_dense_50x50", &entries50, 50);
|
||||
|
||||
let _ = state;
|
||||
}
|
||||
|
||||
criterion_group!(benches, bench_lu_backends);
|
||||
criterion_main!(benches);
|
||||
@@ -34,6 +34,7 @@ pub mod initializer;
|
||||
pub mod inverse;
|
||||
pub mod jacobian;
|
||||
pub mod linear;
|
||||
pub mod linear_faer;
|
||||
pub mod macro_component;
|
||||
pub mod metadata;
|
||||
pub mod scaling;
|
||||
@@ -64,7 +65,8 @@ pub use initializer::{
|
||||
};
|
||||
pub use inverse::{ComponentOutput, Constraint, ConstraintError, ConstraintId};
|
||||
pub use jacobian::JacobianMatrix;
|
||||
pub use linear::NalgebraLuSolver;
|
||||
pub use linear::{make_linear_backend, set_linear_backend_override, DenseLu, NalgebraLuSolver};
|
||||
pub use linear_faer::FaerLuSolver;
|
||||
pub use macro_component::{MacroComponent, MacroComponentSnapshot, PortMapping};
|
||||
pub use metadata::SimulationMetadata;
|
||||
pub use scaling::{equilibrate, unscale_dx};
|
||||
|
||||
@@ -356,3 +356,139 @@ mod tests {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Strangler dispatch (Story 1.5) ─────────────────────────────────────────
|
||||
|
||||
/// Process-wide override for the default dense backend (set by the CLI from
|
||||
/// `solver.linear_backend`). Precedence: explicit `make_linear_backend` name
|
||||
/// → this override → `ENTROPYK_LINEAR_BACKEND` env → `"nalgebra"`.
|
||||
static BACKEND_OVERRIDE: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
|
||||
|
||||
/// Sets the process-wide default dense backend (`Some("faer")` / `Some("nalgebra")`,
|
||||
/// `None` to clear). Additive, additive-only configuration surface.
|
||||
pub fn set_linear_backend_override(name: Option<&str>) {
|
||||
if let Ok(mut guard) = BACKEND_OVERRIDE.write() {
|
||||
*guard = name.map(str::to_string);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dense LU backend in use: nalgebra (default, legacy path) or faer
|
||||
/// (opt-in). Strategies hold this enum directly so the `set_matrix` fast path
|
||||
/// stays available; it also implements [`LinearSolver`] for registry boxing.
|
||||
pub enum DenseLu {
|
||||
/// nalgebra dense LU (default).
|
||||
Nalgebra(NalgebraLuSolver),
|
||||
/// faer dense LU (Story 1.5 opt-in).
|
||||
Faer(crate::linear_faer::FaerLuSolver),
|
||||
}
|
||||
|
||||
impl DenseLu {
|
||||
/// Inherent fast path shared by both backends (see each backend's docs).
|
||||
pub fn set_matrix(&mut self, matrix: &DMatrix<f64>) -> Result<(), SolverCoreError> {
|
||||
match self {
|
||||
Self::Nalgebra(b) => b.set_matrix(matrix),
|
||||
Self::Faer(b) => b.set_matrix(matrix),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LinearSolver for DenseLu {
|
||||
fn set_problem(
|
||||
&mut self,
|
||||
n_equations: usize,
|
||||
n_unknowns: usize,
|
||||
) -> Result<(), SolverCoreError> {
|
||||
match self {
|
||||
Self::Nalgebra(b) => b.set_problem(n_equations, n_unknowns),
|
||||
Self::Faer(b) => b.set_problem(n_equations, n_unknowns),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_linearisation(
|
||||
&mut self,
|
||||
entries: &[(usize, usize, f64)],
|
||||
) -> Result<(), SolverCoreError> {
|
||||
match self {
|
||||
Self::Nalgebra(b) => b.set_linearisation(entries),
|
||||
Self::Faer(b) => b.set_linearisation(entries),
|
||||
}
|
||||
}
|
||||
|
||||
fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError> {
|
||||
match self {
|
||||
Self::Nalgebra(b) => b.solve_in_place(rhs),
|
||||
Self::Faer(b) => b.solve_in_place(rhs),
|
||||
}
|
||||
}
|
||||
|
||||
fn n(&self) -> usize {
|
||||
match self {
|
||||
Self::Nalgebra(b) => b.n(),
|
||||
Self::Faer(b) => b.n(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the dense backend selected by name (or by override/env/default).
|
||||
pub fn make_linear_backend(name: Option<&str>) -> Result<DenseLu, SolverCoreError> {
|
||||
let selected = name
|
||||
.map(str::to_string)
|
||||
.or_else(|| BACKEND_OVERRIDE.read().ok().and_then(|g| g.clone()))
|
||||
.or_else(|| std::env::var("ENTROPYK_LINEAR_BACKEND").ok())
|
||||
.unwrap_or_else(|| "nalgebra".to_string());
|
||||
match selected.as_str() {
|
||||
"nalgebra" => Ok(DenseLu::Nalgebra(NalgebraLuSolver::new())),
|
||||
"faer" => Ok(DenseLu::Faer(crate::linear_faer::FaerLuSolver::new())),
|
||||
other => Err(SolverCoreError::Usage {
|
||||
message: format!("unknown linear backend '{other}' (use 'nalgebra' or 'faer')"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn factory_builds_named_backends_and_rejects_unknown() {
|
||||
assert!(matches!(
|
||||
make_linear_backend(Some("nalgebra")).unwrap(),
|
||||
DenseLu::Nalgebra(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
make_linear_backend(Some("faer")).unwrap(),
|
||||
DenseLu::Faer(_)
|
||||
));
|
||||
assert!(make_linear_backend(Some("mumps")).is_err());
|
||||
// Env-independent check: an explicit override always wins.
|
||||
set_linear_backend_override(Some("nalgebra"));
|
||||
assert!(matches!(
|
||||
make_linear_backend(None).unwrap(),
|
||||
DenseLu::Nalgebra(_)
|
||||
));
|
||||
set_linear_backend_override(None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_selects_default_and_explicit_name_wins() {
|
||||
set_linear_backend_override(Some("faer"));
|
||||
assert!(matches!(
|
||||
make_linear_backend(None).unwrap(),
|
||||
DenseLu::Faer(_)
|
||||
));
|
||||
// Explicit name wins over the override.
|
||||
assert!(matches!(
|
||||
make_linear_backend(Some("nalgebra")).unwrap(),
|
||||
DenseLu::Nalgebra(_)
|
||||
));
|
||||
set_linear_backend_override(None);
|
||||
// After clearing, selection falls back to env-or-nalgebra: just check
|
||||
// it builds a working backend.
|
||||
let mut b = make_linear_backend(None).unwrap();
|
||||
b.set_problem(1, 1).unwrap();
|
||||
b.set_linearisation(&[(0, 0, 2.0)]).unwrap();
|
||||
let mut rhs = [4.0];
|
||||
b.solve_in_place(&mut rhs).unwrap();
|
||||
assert!((rhs[0] - 2.0).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
310
crates/solver/src/linear_faer.rs
Normal file
310
crates/solver/src/linear_faer.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
//! `FaerLuSolver`: dense faer LU backend behind the [`LinearSolver`] lifecycle
|
||||
//! (FR3, Story 1.5 — strangler migration; nalgebra stays the default).
|
||||
//!
|
||||
//! Same contract and pipeline as [`crate::linear::NalgebraLuSolver`] (post-1.4
|
||||
//! audit): identical triplet assembly (zero-fill + `+=`), identical Ruiz
|
||||
//! equilibration (`crate::scaling::equilibrate`), factorization computed once
|
||||
//! in `set_linearisation`, bounds validated BEFORE mutation with the factor
|
||||
//! invalidated on out-of-bounds, zero-panic error paths, zero heap allocation
|
||||
//! in `solve_in_place` (faer solves in place on the stored factorization,
|
||||
//! over a column-major view of the pre-allocated scratch). The only
|
||||
//! intentional difference is the factorization engine: faer's partial-pivot
|
||||
//! LU instead of nalgebra's LU — results match within tolerance, not bitwise.
|
||||
|
||||
use entropyk_solver_core::{LinearSolver, SolverCoreError};
|
||||
use faer::linalg::solvers::{PartialPivLu, SolveCore};
|
||||
use nalgebra::DMatrix;
|
||||
|
||||
/// Stored faer factorization plus the equilibration scalings used to build it.
|
||||
struct Factorized {
|
||||
d_r: Vec<f64>,
|
||||
d_c: Vec<f64>,
|
||||
lu: PartialPivLu<f64>,
|
||||
}
|
||||
|
||||
/// Dense LU backend (faer) behind the object-safe [`LinearSolver`] trait.
|
||||
///
|
||||
/// Scratch buffers are allocated once in `set_problem` so `solve_in_place`
|
||||
/// performs zero heap allocation.
|
||||
pub struct FaerLuSolver {
|
||||
n_rows: usize,
|
||||
n_cols: usize,
|
||||
/// Assembled (unscaled) matrix (nalgebra storage, shared with the
|
||||
/// equilibration pipeline).
|
||||
matrix: Option<DMatrix<f64>>,
|
||||
factor: Option<Factorized>,
|
||||
/// Scratch: scaled rhs / unscaled step (allocated in `set_problem`).
|
||||
delta: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Default for FaerLuSolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FaerLuSolver {
|
||||
/// Creates an unconfigured backend (call [`LinearSolver::set_problem`]).
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
n_rows: 0,
|
||||
n_cols: 0,
|
||||
matrix: None,
|
||||
factor: None,
|
||||
delta: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inherent fast path: copy an already-assembled dense matrix and factor
|
||||
/// it (one `copy_from`, no triplet conversion).
|
||||
pub fn set_matrix(&mut self, matrix: &DMatrix<f64>) -> Result<(), SolverCoreError> {
|
||||
let stored = self.matrix.as_mut().ok_or_else(|| SolverCoreError::Usage {
|
||||
message: "set_problem must be called before set_matrix".to_string(),
|
||||
})?;
|
||||
if stored.nrows() != matrix.nrows() || stored.ncols() != matrix.ncols() {
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: format!(
|
||||
"matrix shape {}x{} does not match problem {}x{}",
|
||||
matrix.nrows(),
|
||||
matrix.ncols(),
|
||||
stored.nrows(),
|
||||
stored.ncols()
|
||||
),
|
||||
});
|
||||
}
|
||||
stored.copy_from(matrix);
|
||||
self.factorize()
|
||||
}
|
||||
|
||||
/// Equilibrate + scale + factorize the stored matrix (square path).
|
||||
fn factorize(&mut self) -> Result<(), SolverCoreError> {
|
||||
let Some(matrix) = self.matrix.as_ref() else {
|
||||
self.factor = None;
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: "set_problem must be called before factorizing".to_string(),
|
||||
});
|
||||
};
|
||||
if matrix.nrows() != matrix.ncols() {
|
||||
// Non-square problems have no LU path; solve_in_place reports Usage.
|
||||
self.factor = None;
|
||||
return Ok(());
|
||||
}
|
||||
let n = matrix.nrows();
|
||||
let (d_r, d_c) = crate::scaling::equilibrate(matrix);
|
||||
// Scaled matrix as a faer Mat (column-major, one copy at factor time).
|
||||
let scaled = faer::Mat::from_fn(n, n, |i, j| matrix[(i, j)] * d_r[i] * d_c[j]);
|
||||
self.factor = Some(Factorized {
|
||||
d_r,
|
||||
d_c,
|
||||
lu: PartialPivLu::new(scaled.as_ref()),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl LinearSolver for FaerLuSolver {
|
||||
fn set_problem(
|
||||
&mut self,
|
||||
n_equations: usize,
|
||||
n_unknowns: usize,
|
||||
) -> Result<(), SolverCoreError> {
|
||||
if n_equations == 0 || n_unknowns == 0 {
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: format!("zero-sized problem {n_equations}x{n_unknowns}"),
|
||||
});
|
||||
}
|
||||
self.n_rows = n_equations;
|
||||
self.n_cols = n_unknowns;
|
||||
self.matrix = Some(DMatrix::zeros(n_equations, n_unknowns));
|
||||
self.factor = None;
|
||||
// Scratch buffer for the zero-allocation solve path.
|
||||
self.delta = vec![0.0; n_unknowns];
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_linearisation(
|
||||
&mut self,
|
||||
entries: &[(usize, usize, f64)],
|
||||
) -> Result<(), SolverCoreError> {
|
||||
let (n_rows, n_cols) = (self.n_rows, self.n_cols);
|
||||
if self.matrix.is_none() {
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: "set_problem must be called before set_linearisation".to_string(),
|
||||
});
|
||||
}
|
||||
// Validate bounds BEFORE touching the matrix (1.4 audit contract).
|
||||
for &(row, col, _) in entries {
|
||||
if row >= n_rows || col >= n_cols {
|
||||
self.factor = None;
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: format!("entry ({row},{col}) out of bounds {n_rows}x{n_cols}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
let matrix = self.matrix.as_mut().ok_or_else(|| SolverCoreError::Usage {
|
||||
message: "set_problem must be called before set_linearisation".to_string(),
|
||||
})?;
|
||||
matrix.fill(0.0);
|
||||
for &(row, col, value) in entries {
|
||||
matrix[(row, col)] += value;
|
||||
}
|
||||
self.factorize()
|
||||
}
|
||||
|
||||
fn solve_in_place(&mut self, rhs: &mut [f64]) -> Result<(), SolverCoreError> {
|
||||
if rhs.len() != self.n_rows || self.n_rows != self.n_cols {
|
||||
return Err(SolverCoreError::Usage {
|
||||
message: format!(
|
||||
"solve_in_place needs a square rhs of len {}, got {} (problem {}x{})",
|
||||
self.n_rows,
|
||||
rhs.len(),
|
||||
self.n_rows,
|
||||
self.n_cols
|
||||
),
|
||||
});
|
||||
}
|
||||
let n = self.n_rows;
|
||||
// Disjoint field borrows: factorization + scratch (zero alloc).
|
||||
let Self { factor, delta, .. } = self;
|
||||
let factor = factor.as_ref().ok_or_else(|| SolverCoreError::Usage {
|
||||
message: "set_linearisation must be called before solve_in_place".to_string(),
|
||||
})?;
|
||||
// Scaled right-hand side: D_r · b (into the scratch).
|
||||
for i in 0..n {
|
||||
delta[i] = rhs[i] * factor.d_r[i];
|
||||
}
|
||||
// faer solves in place on a column-major view of the scratch.
|
||||
let rhs_view = faer::MatMut::from_column_major_slice_mut(&mut delta[..n], n, 1);
|
||||
factor.lu.solve_in_place_with_conj(faer::Conj::No, rhs_view);
|
||||
// Undo the column scaling in place: x_i = D_c,i · y_i.
|
||||
for i in 0..n {
|
||||
delta[i] *= factor.d_c[i];
|
||||
}
|
||||
if delta[..n].iter().all(|v| v.is_finite()) {
|
||||
rhs.copy_from_slice(&delta[..n]);
|
||||
Ok(())
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"faer LU solve produced a non-finite step - Jacobian may contain NaN/Inf"
|
||||
);
|
||||
Err(SolverCoreError::InvalidSystem {
|
||||
message: "linear solve produced a non-finite step".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn n(&self) -> usize {
|
||||
self.n_rows
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::jacobian::JacobianMatrix;
|
||||
|
||||
/// Deterministic dense system (well-conditioned, diagonally dominant).
|
||||
fn dense_entries(n: usize) -> Vec<(usize, usize, f64)> {
|
||||
let mut entries = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let v = if i == j {
|
||||
4.0 + 0.1 * (i as f64)
|
||||
} else {
|
||||
1.0 / (1.0 + (i as f64 - j as f64).abs())
|
||||
};
|
||||
entries.push((i, j, v));
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_with_jacobian_matrix_solve() {
|
||||
for n in [1, 2, 5, 12] {
|
||||
let entries = dense_entries(n);
|
||||
let jm = JacobianMatrix::from_builder(&entries, n, n);
|
||||
let residuals: Vec<f64> = (0..n)
|
||||
.map(|i| (i as f64 * 0.7 - 1.3).sin() * 100.0)
|
||||
.collect();
|
||||
|
||||
let legacy = jm.solve(&residuals).expect("legacy solve");
|
||||
|
||||
let mut backend = FaerLuSolver::new();
|
||||
backend.set_problem(n, n).unwrap();
|
||||
backend.set_linearisation(&entries).unwrap();
|
||||
let mut rhs: Vec<f64> = residuals.iter().map(|r| -*r).collect();
|
||||
backend.solve_in_place(&mut rhs).unwrap();
|
||||
|
||||
for i in 0..n {
|
||||
// faer ≠ nalgebra bitwise: tolerance, not exact equality.
|
||||
assert!(
|
||||
(legacy[i] - rhs[i]).abs() <= 1e-9 * legacy[i].abs().max(1.0),
|
||||
"n={n} i={i}: legacy={} faer={}",
|
||||
legacy[i],
|
||||
rhs[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factorization_reuse_across_rhs() {
|
||||
let n = 6;
|
||||
let entries = dense_entries(n);
|
||||
let jm = JacobianMatrix::from_builder(&entries, n, n);
|
||||
let mut backend = FaerLuSolver::new();
|
||||
backend.set_problem(n, n).unwrap();
|
||||
backend.set_matrix(jm.as_matrix()).unwrap();
|
||||
|
||||
for k in 0..2 {
|
||||
let residuals: Vec<f64> = (0..n).map(|i| (i + k) as f64 * 3.1 - 5.0).collect();
|
||||
let legacy = jm.solve(&residuals).expect("legacy solve");
|
||||
let mut rhs: Vec<f64> = residuals.iter().map(|r| -*r).collect();
|
||||
backend.solve_in_place(&mut rhs).unwrap();
|
||||
for i in 0..n {
|
||||
assert!(
|
||||
(legacy[i] - rhs[i]).abs() <= 1e-9 * legacy[i].abs().max(1.0),
|
||||
"reuse k={k} i={i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_paths_and_stale_factor_invalidation() {
|
||||
let mut backend = FaerLuSolver::new();
|
||||
assert!(backend.set_problem(0, 2).is_err());
|
||||
backend.set_problem(2, 2).unwrap();
|
||||
// Solve before set_linearisation → Usage.
|
||||
assert!(matches!(
|
||||
backend.solve_in_place(&mut [1.0, 2.0]),
|
||||
Err(SolverCoreError::Usage { .. })
|
||||
));
|
||||
// Factor a good matrix, then an OOB entry: the stale factorization
|
||||
// must be invalidated (1.4 audit regression).
|
||||
backend
|
||||
.set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0)])
|
||||
.unwrap();
|
||||
assert!(backend.set_linearisation(&[(2, 0, 1.0)]).is_err());
|
||||
assert!(matches!(
|
||||
backend.solve_in_place(&mut [1.0, 2.0]),
|
||||
Err(SolverCoreError::Usage { .. })
|
||||
));
|
||||
assert_eq!(backend.n(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_matrix_entries_are_rejected() {
|
||||
let mut backend = FaerLuSolver::new();
|
||||
backend.set_problem(2, 2).unwrap();
|
||||
backend
|
||||
.set_linearisation(&[(0, 0, f64::NAN), (1, 1, 1.0)])
|
||||
.unwrap();
|
||||
let mut rhs = vec![1.0, 2.0];
|
||||
assert!(matches!(
|
||||
backend.solve_in_place(&mut rhs),
|
||||
Err(SolverCoreError::InvalidSystem { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -345,10 +345,13 @@ impl Solver for NewtonConfig {
|
||||
let mut frozen_count: usize = 0;
|
||||
let mut force_recompute: bool = true;
|
||||
|
||||
// LinearSolver backend (FR3): factorizes at each fresh assembly and
|
||||
// reuses the factorization (with cached scalings) across frozen
|
||||
// iterations and repeated solves — bit-identical to JacobianMatrix::solve.
|
||||
let mut linear_backend = crate::linear::NalgebraLuSolver::new();
|
||||
// LinearSolver backend (FR3/Story 1.5): nalgebra by default, faer via
|
||||
// config/env dispatch. Factorizes at each fresh assembly and reuses
|
||||
// the factorization (with cached scalings) across frozen iterations.
|
||||
let mut linear_backend =
|
||||
crate::linear::make_linear_backend(None).map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to select linear backend: {e}"),
|
||||
})?;
|
||||
linear_backend
|
||||
.set_problem(n_equations, n_state)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
|
||||
@@ -122,9 +122,12 @@ impl Solver for PtcConfig {
|
||||
let mut jacobian_builder = JacobianBuilder::new();
|
||||
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
|
||||
|
||||
// LinearSolver backend (FR3): factorizes at each fresh assembly;
|
||||
// bit-identical to JacobianMatrix::solve.
|
||||
let mut linear_backend = crate::linear::NalgebraLuSolver::new();
|
||||
// LinearSolver backend (FR3/Story 1.5): nalgebra by default, faer via
|
||||
// config/env dispatch; factorizes at each fresh assembly.
|
||||
let mut linear_backend =
|
||||
crate::linear::make_linear_backend(None).map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to select linear backend: {e}"),
|
||||
})?;
|
||||
linear_backend
|
||||
.set_problem(n_equations, n_state)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
|
||||
@@ -335,6 +335,26 @@ impl Solver for PicardConfig {
|
||||
let mut divergence_count: usize = 0;
|
||||
let mut previous_norm: f64;
|
||||
|
||||
// Solver bounds mask (pressure floor + bounded control variables):
|
||||
// Picard's relaxed update has no line search, so without clipping it
|
||||
// can drive a calibration z-factor far outside its bounds (observed:
|
||||
// z_ua → −2.8 on an SDT calibration), then crash the components.
|
||||
// Saturated-controller actuator slots are EXCLUDED: they carry their
|
||||
// own saturation semantics (anti-windup integrator + S(x) inside the
|
||||
// controller residual) and hard-clamping them here breaks that dynamic.
|
||||
let mut clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
|
||||
.map(|i| system.get_solver_bounds_for_state_index(i))
|
||||
.collect();
|
||||
{
|
||||
let n_sat = system.saturated_controllers_mut().count();
|
||||
for i in 0..n_sat {
|
||||
let u_idx = system.saturated_u_index(i);
|
||||
if u_idx < clipping_mask.len() {
|
||||
clipping_mask[u_idx] = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
|
||||
let mut best_state: Vec<f64> = vec![0.0; n_state];
|
||||
let mut best_residual: f64;
|
||||
@@ -425,6 +445,16 @@ impl Solver for PicardConfig {
|
||||
} else {
|
||||
Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);
|
||||
}
|
||||
// Clamp the relaxed/extrapolated iterate into solver bounds (see
|
||||
// the mask comment above): bounded control variables (calibration
|
||||
// z-factors, openings) and pressure floors.
|
||||
for (i, s) in state.iter_mut().enumerate() {
|
||||
if let Some((min, max)) = &clipping_mask[i] {
|
||||
if min.is_finite() && max.is_finite() && min <= max {
|
||||
*s = s.clamp(*min, *max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute new residuals. Recoverable domain violation → typed
|
||||
// outcome; fatal → InvalidSystem flattening.
|
||||
|
||||
@@ -224,6 +224,11 @@ pub struct System {
|
||||
/// Registry of component names for constraint validation.
|
||||
/// Maps human-readable names (e.g., "evaporator") to NodeIndex.
|
||||
component_names: HashMap<String, NodeIndex>,
|
||||
/// Per-component calibration indices snapshot, captured at `finalize()` time.
|
||||
/// Lets external readers (e.g. result extraction) learn which Z-factors
|
||||
/// (z_ua, z_dp, z_flow, …) were promoted to free unknowns and where they
|
||||
/// live in the state vector, so the solved values can be surfaced to the user.
|
||||
calib_indices_by_name: HashMap<String, entropyk_core::CalibIndices>,
|
||||
finalized: bool,
|
||||
total_state_len: usize,
|
||||
/// When `true` (default), `finalize` rejects **over-constrained** systems.
|
||||
@@ -246,6 +251,7 @@ impl System {
|
||||
saturated_controllers: Vec::new(),
|
||||
free_actuators: Vec::new(),
|
||||
component_names: HashMap::new(),
|
||||
calib_indices_by_name: HashMap::new(),
|
||||
finalized: false,
|
||||
total_state_len: 0,
|
||||
enforce_dof_gate: true,
|
||||
@@ -682,6 +688,10 @@ impl System {
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the per-component calib map so external readers (result
|
||||
// extraction) can surface solved Z-factor values (z_ua, z_dp, …).
|
||||
self.calib_indices_by_name = comp_calib_indices;
|
||||
|
||||
// Wire physical thermal couplings (Story 3.4 completion): each coupling
|
||||
// owns one state unknown Q [W] at `coupling_state_index(i)`. The
|
||||
// cold-side receiver component (e.g. `ThermalLoad`) reads Q in its
|
||||
@@ -1145,6 +1155,15 @@ impl System {
|
||||
self.component_names.keys().map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Per-component `CalibIndices` snapshot captured at `finalize()`.
|
||||
/// Each `Some(idx)` slot (z_ua, z_dp, z_flow, z_flow_eco, z_power, z_etav,
|
||||
/// actuator) means that factor was promoted to a free solver unknown and
|
||||
/// its solved value lives at `state[idx]`. Used by result extraction to
|
||||
/// surface solved calibration/control values to the user.
|
||||
pub fn calib_indices_by_name(&self) -> &HashMap<String, entropyk_core::CalibIndices> {
|
||||
&self.calib_indices_by_name
|
||||
}
|
||||
|
||||
/// Returns a reference to the component stored at the given node index.
|
||||
///
|
||||
/// # Panics
|
||||
@@ -2514,11 +2533,11 @@ impl System {
|
||||
self.total_state_len + self.inverse_control.mapping_count() + i
|
||||
}
|
||||
|
||||
fn saturated_base_index(&self) -> usize {
|
||||
pub fn saturated_base_index(&self) -> usize {
|
||||
self.total_state_len + self.inverse_control.mapping_count() + self.coupling_residual_count()
|
||||
}
|
||||
|
||||
fn saturated_u_index(&self, i: usize) -> usize {
|
||||
pub fn saturated_u_index(&self, i: usize) -> usize {
|
||||
self.saturated_base_index() + 2 * i
|
||||
}
|
||||
|
||||
|
||||
83
crates/solver/tests/faer_backend_parity.rs
Normal file
83
crates/solver/tests/faer_backend_parity.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
//! Story 1.5 AC#2/#3: the faer dense backend solves the reference cycles with
|
||||
//! results matching the nalgebra baseline within tolerance (not bitwise —
|
||||
//! different factorization engines), via the strangler dispatch.
|
||||
|
||||
mod common;
|
||||
|
||||
use entropyk_solver::linear::{make_linear_backend, set_linear_backend_override, DenseLu};
|
||||
use entropyk_solver::{LinearSolver, Solver};
|
||||
|
||||
/// Per-value tolerance matching the golden-snapshot comparison policy.
|
||||
const STATE_RTOL: f64 = 1e-6;
|
||||
const STATE_ATOL: f64 = 1.0; // Pa / J/kg absolute floor
|
||||
|
||||
fn solve_all_cycles() -> Vec<Vec<f64>> {
|
||||
[
|
||||
common::build_reference_cycle_a(),
|
||||
common::build_reference_cycle_b(),
|
||||
common::build_reference_cycle_c(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|mut system| {
|
||||
common::solve_reference_system_with_state(&mut system)
|
||||
.state
|
||||
.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faer_path_dispatch_and_reference_cycle_parity() {
|
||||
// 1. Unknown override must fail the solve with the dispatch error (the
|
||||
// dispatch is on the strategy path, not dead code).
|
||||
set_linear_backend_override(Some("definitely-not-a-backend"));
|
||||
let mut system = common::build_reference_cycle_a();
|
||||
let result = entropyk_solver::FallbackSolver::default_solver().solve(&mut system);
|
||||
assert!(result.is_err(), "unknown backend must fail the solve");
|
||||
|
||||
// 2. Baseline (nalgebra) on the three reference cycles.
|
||||
set_linear_backend_override(Some("nalgebra"));
|
||||
let baseline = solve_all_cycles();
|
||||
|
||||
// 3. Same cycles on faer; converged states must match within tolerance.
|
||||
set_linear_backend_override(Some("faer"));
|
||||
let faer = solve_all_cycles();
|
||||
|
||||
set_linear_backend_override(None);
|
||||
|
||||
assert_eq!(baseline.len(), faer.len());
|
||||
for (cycle_idx, (base, alt)) in baseline.iter().zip(faer.iter()).enumerate() {
|
||||
assert_eq!(
|
||||
base.len(),
|
||||
alt.len(),
|
||||
"cycle {cycle_idx}: state length differs"
|
||||
);
|
||||
for (i, (b, a)) in base.iter().zip(alt.iter()).enumerate() {
|
||||
let scale = b.abs().max(a.abs()).max(STATE_ATOL);
|
||||
assert!(
|
||||
(b - a).abs() <= STATE_RTOL * scale,
|
||||
"cycle {cycle_idx} state[{i}]: nalgebra={b} faer={a} (rel diff {})",
|
||||
(b - a).abs() / scale
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_lu_enum_dispatches_both_engines() {
|
||||
for name in ["nalgebra", "faer"] {
|
||||
let mut backend = make_linear_backend(Some(name)).unwrap();
|
||||
backend.set_problem(2, 2).unwrap();
|
||||
backend
|
||||
.set_linearisation(&[(0, 0, 2.0), (1, 1, 1.0)])
|
||||
.unwrap();
|
||||
let mut rhs = vec![4.0, 3.0];
|
||||
backend.solve_in_place(&mut rhs).unwrap();
|
||||
assert!((rhs[0] - 2.0).abs() < 1e-9, "{name}: {}", rhs[0]);
|
||||
assert!((rhs[1] - 3.0).abs() < 1e-9, "{name}: {}", rhs[1]);
|
||||
}
|
||||
assert!(matches!(
|
||||
make_linear_backend(Some("faer")).unwrap(),
|
||||
DenseLu::Faer(_)
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user