Snapshot WIP: Probe calibration path, faer LU backend, and BPHX phase-change duty.
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:
2026-07-19 21:44:01 +02:00
parent 2f1f7ecb80
commit 3808e0f11b
39 changed files with 3648 additions and 265 deletions

View File

@@ -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

View 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);

View File

@@ -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};

View File

@@ -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);
}
}

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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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
}

View 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(_)
));
}