Fix code review findings for Story 5-1

- Fixed Critical issue: Wired up _state to the underlying HeatExchanger boundary conditions so the Newton-Raphson solver actually sees numerical gradients.
- Fixed Critical issue: Bubble up FluidBackend errors via ComponentError::CalculationFailed instead of silently swallowing backend evaluation failures.
- Fixed Medium issue: Connected condenser_with_backend into the eurovent.rs system architecture so the demo solves instead of just printing output.
- Fixed Medium issue: Removed heavy FluidId clones inside query loop.
- Fixed Low issue: Added physical validations to HxSideConditions.
This commit is contained in:
Sepehr
2026-02-20 21:25:44 +01:00
parent be70a7a6c7
commit 73ad750f31
9 changed files with 5590 additions and 34 deletions

View File

@@ -0,0 +1,261 @@
//! Integration tests for Story 4.7: Convergence Criteria & Validation.
//!
//! Tests cover all behaviour-level Acceptance Criteria:
//! - AC #7: ConvergenceCriteria integrates with Newton/Picard solvers
//! - AC #8: `convergence_report` field in `ConvergedState` (Some when criteria set, None by default)
//! - Backward compatibility: existing raw-tolerance workflow unchanged
use entropyk_solver::{
CircuitConvergence, ConvergenceCriteria, ConvergenceReport, ConvergedState, ConvergenceStatus,
FallbackSolver, FallbackConfig, NewtonConfig, PicardConfig, Solver, System,
};
use approx::assert_relative_eq;
// ─────────────────────────────────────────────────────────────────────────────
// AC #8: ConvergenceReport in ConvergedState
// ─────────────────────────────────────────────────────────────────────────────
/// Test that `ConvergedState::new` does NOT attach a report (backward-compat).
#[test]
fn test_converged_state_new_no_report() {
let state = ConvergedState::new(
vec![1.0, 2.0],
10,
1e-8,
ConvergenceStatus::Converged,
);
assert!(state.convergence_report.is_none(), "ConvergedState::new should not attach a report");
}
/// Test that `ConvergedState::with_report` attaches a report.
#[test]
fn test_converged_state_with_report_attaches_report() {
let report = ConvergenceReport {
per_circuit: vec![CircuitConvergence {
circuit_id: 0,
pressure_ok: true,
mass_ok: true,
energy_ok: true,
converged: true,
}],
globally_converged: true,
};
let state = ConvergedState::with_report(
vec![1.0, 2.0],
10,
1e-8,
ConvergenceStatus::Converged,
report,
);
assert!(state.convergence_report.is_some(), "with_report should attach a report");
assert!(state.convergence_report.unwrap().is_globally_converged());
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #7: ConvergenceCriteria builder methods
// ─────────────────────────────────────────────────────────────────────────────
/// Test that `NewtonConfig::with_convergence_criteria` stores the criteria.
#[test]
fn test_newton_with_convergence_criteria_builder() {
let criteria = ConvergenceCriteria::default();
let cfg = NewtonConfig::default().with_convergence_criteria(criteria.clone());
assert!(cfg.convergence_criteria.is_some());
let stored = cfg.convergence_criteria.unwrap();
assert_relative_eq!(stored.pressure_tolerance_pa, criteria.pressure_tolerance_pa);
}
/// Test that `PicardConfig::with_convergence_criteria` stores the criteria.
#[test]
fn test_picard_with_convergence_criteria_builder() {
let criteria = ConvergenceCriteria {
pressure_tolerance_pa: 0.5,
mass_balance_tolerance_kgs: 1e-10,
energy_balance_tolerance_w: 1e-4,
};
let cfg = PicardConfig::default().with_convergence_criteria(criteria.clone());
assert!(cfg.convergence_criteria.is_some());
let stored = cfg.convergence_criteria.unwrap();
assert_relative_eq!(stored.pressure_tolerance_pa, 0.5);
assert_relative_eq!(stored.mass_balance_tolerance_kgs, 1e-10);
}
/// Test that `FallbackSolver::with_convergence_criteria` delegates to both sub-solvers.
#[test]
fn test_fallback_with_convergence_criteria_delegates() {
let criteria = ConvergenceCriteria::default();
let solver = FallbackSolver::default_solver().with_convergence_criteria(criteria.clone());
assert!(solver.newton_config.convergence_criteria.is_some());
assert!(solver.picard_config.convergence_criteria.is_some());
let newton_c = solver.newton_config.convergence_criteria.unwrap();
let picard_c = solver.picard_config.convergence_criteria.unwrap();
assert_relative_eq!(newton_c.pressure_tolerance_pa, criteria.pressure_tolerance_pa);
assert_relative_eq!(picard_c.pressure_tolerance_pa, criteria.pressure_tolerance_pa);
}
/// Test backward-compat: Newton without criteria → `convergence_criteria` is `None`.
#[test]
fn test_newton_without_criteria_is_none() {
let cfg = NewtonConfig::default();
assert!(cfg.convergence_criteria.is_none(), "Default Newton should have no criteria");
}
/// Test backward-compat: Picard without criteria → `convergence_criteria` is `None`.
#[test]
fn test_picard_without_criteria_is_none() {
let cfg = PicardConfig::default();
assert!(cfg.convergence_criteria.is_none(), "Default Picard should have no criteria");
}
/// Test that Newton with empty system returns Err (no panic when criteria set).
#[test]
fn test_newton_with_criteria_empty_system_no_panic() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig::default()
.with_convergence_criteria(ConvergenceCriteria::default());
// Empty system → wrapped error, no panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
/// Test that Picard with empty system returns Err (no panic when criteria set).
#[test]
fn test_picard_with_criteria_empty_system_no_panic() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig::default()
.with_convergence_criteria(ConvergenceCriteria::default());
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
// ─────────────────────────────────────────────────────────────────────────────
// ConvergenceCriteria type tests
// ─────────────────────────────────────────────────────────────────────────────
/// AC #1: Default pressure tolerance is 1.0 Pa.
#[test]
fn test_criteria_default_pressure_tolerance() {
let c = ConvergenceCriteria::default();
assert_relative_eq!(c.pressure_tolerance_pa, 1.0);
}
/// AC #2: Default mass balance tolerance is 1e-9 kg/s.
#[test]
fn test_criteria_default_mass_tolerance() {
let c = ConvergenceCriteria::default();
assert_relative_eq!(c.mass_balance_tolerance_kgs, 1e-9);
}
/// AC #3: Default energy balance tolerance is 1e-3 W (= 1e-6 kW).
#[test]
fn test_criteria_default_energy_tolerance() {
let c = ConvergenceCriteria::default();
assert_relative_eq!(c.energy_balance_tolerance_w, 1e-3);
}
/// AC #5: Global convergence only when ALL circuits converge.
#[test]
fn test_global_convergence_requires_all_circuits() {
// 3 circuits, one fails → not globally converged
let report = ConvergenceReport {
per_circuit: vec![
CircuitConvergence { circuit_id: 0, pressure_ok: true, mass_ok: true, energy_ok: true, converged: true },
CircuitConvergence { circuit_id: 1, pressure_ok: true, mass_ok: true, energy_ok: true, converged: true },
CircuitConvergence { circuit_id: 2, pressure_ok: false, mass_ok: true, energy_ok: true, converged: false },
],
globally_converged: false,
};
assert!(!report.is_globally_converged());
}
/// AC #5: Single-circuit system is a degenerate case of global convergence.
#[test]
fn test_single_circuit_global_convergence() {
let report = ConvergenceReport {
per_circuit: vec![
CircuitConvergence { circuit_id: 0, pressure_ok: true, mass_ok: true, energy_ok: true, converged: true },
],
globally_converged: true,
};
assert!(report.is_globally_converged());
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #7: Integration Validation (Actual Solve)
// ─────────────────────────────────────────────────────────────────────────────
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, SystemState};
use entropyk_components::port::ConnectedPort;
struct MockConvergingComponent;
impl Component for MockConvergingComponent {
fn compute_residuals(&self, state: &SystemState, residuals: &mut ResidualVector) -> Result<(), ComponentError> {
// Simple linear system will converge in 1 step
residuals[0] = state[0] - 5.0;
residuals[1] = state[1] - 10.0;
Ok(())
}
fn jacobian_entries(&self, _state: &SystemState, jacobian: &mut JacobianBuilder) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
jacobian.add_entry(1, 1, 1.0);
Ok(())
}
fn n_equations(&self) -> usize { 2 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
}
#[test]
fn test_newton_with_criteria_single_circuit() {
let mut sys = System::new();
let node1 = sys.add_component(Box::new(MockConvergingComponent));
let node2 = sys.add_component(Box::new(MockConvergingComponent));
sys.add_edge(node1, node2).unwrap();
sys.finalize().unwrap();
let criteria = ConvergenceCriteria {
pressure_tolerance_pa: 1.0,
mass_balance_tolerance_kgs: 1e-1,
energy_balance_tolerance_w: 1e-1,
};
let mut solver = NewtonConfig::default().with_convergence_criteria(criteria);
let result = solver.solve(&mut sys).expect("Solver should converge");
// Check that we got a report back
assert!(result.convergence_report.is_some());
let report = result.convergence_report.unwrap();
assert!(report.is_globally_converged());
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #7: Old tolerance field retained for backward-compat
// ─────────────────────────────────────────────────────────────────────────────
/// Test that old `tolerance` field is still accessible after setting criteria.
#[test]
fn test_backward_compat_tolerance_field_survives() {
let criteria = ConvergenceCriteria::default();
let cfg = NewtonConfig {
tolerance: 1e-8,
..Default::default()
}.with_convergence_criteria(criteria);
// tolerance is still 1e-8 (not overwritten by criteria)
assert_relative_eq!(cfg.tolerance, 1e-8);
assert!(cfg.convergence_criteria.is_some());
}

View File

@@ -0,0 +1,374 @@
//! Integration tests for Story 4.8: Jacobian-Freezing Optimization
//!
//! Tests cover:
//! - AC #1: `JacobianFreezingConfig` default and builder API
//! - AC #2: Frozen Jacobian converges correctly on a simple system
//! - AC #3: Auto-recompute on residual increase (divergence trend)
//! - AC #4: Backward compatibility — no freezing by default
use approx::assert_relative_eq;
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, SystemState};
use entropyk_solver::{
solver::{JacobianFreezingConfig, NewtonConfig, Solver},
System,
};
// ─────────────────────────────────────────────────────────────────────────────
// Mock Components for Testing
// ─────────────────────────────────────────────────────────────────────────────
/// A simple linear component whose residual is r_i = x_i - target_i.
/// The Jacobian is the identity. Newton converges in 1 step from any start.
struct LinearTargetSystem {
targets: Vec<f64>,
}
impl LinearTargetSystem {
fn new(targets: Vec<f64>) -> Self {
Self { targets }
}
}
impl Component for LinearTargetSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for (i, &t) in self.targets.iter().enumerate() {
residuals[i] = state[i] - t;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
for i in 0..self.targets.len() {
jacobian.add_entry(i, i, 1.0);
}
Ok(())
}
fn n_equations(&self) -> usize {
self.targets.len()
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// A mildly non-linear component: r_i = (x_i - target_i)^3.
/// Jacobian: J_ii = 3*(x_i - target_i)^2.
/// Newton converges but needs multiple iterations from a distant start.
struct CubicTargetSystem {
targets: Vec<f64>,
}
impl CubicTargetSystem {
fn new(targets: Vec<f64>) -> Self {
Self { targets }
}
}
impl Component for CubicTargetSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for (i, &t) in self.targets.iter().enumerate() {
let d = state[i] - t;
residuals[i] = d * d * d;
}
Ok(())
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
for (i, &t) in self.targets.iter().enumerate() {
let d = state[i] - t;
let entry = 3.0 * d * d;
// Guard against zero diagonal (would make Jacobian singular at solution)
jacobian.add_entry(i, i, if entry.abs() < 1e-15 { 1.0 } else { entry });
}
Ok(())
}
fn n_equations(&self) -> usize {
self.targets.len()
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
fn build_system_with_linear_targets(targets: Vec<f64>) -> System {
let mut sys = System::new();
let n0 = sys.add_component(Box::new(LinearTargetSystem::new(targets)));
sys.add_edge(n0, n0).unwrap();
sys.finalize().unwrap();
sys
}
fn build_system_with_cubic_targets(targets: Vec<f64>) -> System {
let mut sys = System::new();
let n0 = sys.add_component(Box::new(CubicTargetSystem::new(targets)));
sys.add_edge(n0, n0).unwrap();
sys.finalize().unwrap();
sys
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: JacobianFreezingConfig — defaults and builder
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_jacobian_freezing_config_defaults() {
let cfg = JacobianFreezingConfig::default();
assert_eq!(cfg.max_frozen_iters, 3);
assert_relative_eq!(cfg.threshold, 0.1);
}
#[test]
fn test_jacobian_freezing_config_custom() {
let cfg = JacobianFreezingConfig {
max_frozen_iters: 5,
threshold: 0.2,
};
assert_eq!(cfg.max_frozen_iters, 5);
assert_relative_eq!(cfg.threshold, 0.2);
}
#[test]
fn test_with_jacobian_freezing_builder() {
let config = NewtonConfig::default().with_jacobian_freezing(JacobianFreezingConfig {
max_frozen_iters: 4,
threshold: 0.15,
});
let freeze = config.jacobian_freezing.expect("Should be Some");
assert_eq!(freeze.max_frozen_iters, 4);
assert_relative_eq!(freeze.threshold, 0.15);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #4: Backward compatibility — no freezing by default
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_no_jacobian_freezing_by_default() {
let cfg = NewtonConfig::default();
assert!(
cfg.jacobian_freezing.is_none(),
"Jacobian freezing should be None by default (backward-compatible)"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #2: Frozen Jacobian converges correctly
// ─────────────────────────────────────────────────────────────────────────────
/// On a linear system (identity Jacobian), the solver converges in 1 iteration
/// regardless of whether freezing is enabled. This verifies that freezing does
/// not break the basic convergence behaviour.
#[test]
fn test_frozen_jacobian_converges_linear_system() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_linear_targets(targets.clone());
let mut solver = NewtonConfig::default().with_jacobian_freezing(JacobianFreezingConfig {
max_frozen_iters: 3,
threshold: 0.1,
});
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert!(converged.is_converged());
assert!(
converged.final_residual < 1e-6,
"Residual should be below tolerance"
);
// Linear system converges in exactly 1 Newton step
assert_eq!(converged.iterations, 1);
}
/// On a cubic system starting far from the root, Newton needs several iterations.
/// With freezing enabled the solver must still converge (possibly in more
/// iterations than without freezing, but it must converge).
#[test]
fn test_frozen_jacobian_converges_cubic_system() {
let targets = vec![1.0, 2.0];
let mut sys = build_system_with_cubic_targets(targets.clone());
let mut solver = NewtonConfig {
max_iterations: 200,
tolerance: 1e-6,
..Default::default()
}
.with_jacobian_freezing(JacobianFreezingConfig {
max_frozen_iters: 2,
threshold: 0.05,
});
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert!(converged.is_converged());
assert!(
converged.final_residual < 1e-6,
"Residual should be below tolerance"
);
}
/// Verify that freezing does not alter the solution for a linear system
/// (same final state as without freezing).
#[test]
fn test_frozen_jacobian_same_solution_as_standard_newton() {
let targets = vec![500_000.0, 250_000.0];
// Without freezing
let mut sys1 = build_system_with_linear_targets(targets.clone());
let mut solver1 = NewtonConfig::default();
let res1 = solver1.solve(&mut sys1).expect("standard should converge");
// With freezing
let mut sys2 = build_system_with_linear_targets(targets.clone());
let mut solver2 = NewtonConfig::default().with_jacobian_freezing(JacobianFreezingConfig {
max_frozen_iters: 3,
threshold: 0.1,
});
let res2 = solver2.solve(&mut sys2).expect("frozen should converge");
assert_relative_eq!(res1.state[0], res2.state[0], max_relative = 1e-10);
assert_relative_eq!(res1.state[1], res2.state[1], max_relative = 1e-10);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: Auto-recompute on divergence trend
// ─────────────────────────────────────────────────────────────────────────────
/// With an extremely loose threshold (1.0 → never freeze) we should get
/// identical behaviour to a standard Newton solver.
#[test]
fn test_freeze_threshold_1_never_freezes() {
let targets = vec![300_000.0, 400_000.0];
// Threshold = 1.0 means ratio must be < 0.0 which can never happen,
// so force_recompute is always set → effectively no freezing.
let mut sys = build_system_with_linear_targets(targets.clone());
let mut solver = NewtonConfig::default().with_jacobian_freezing(JacobianFreezingConfig {
max_frozen_iters: 10,
threshold: 1.0,
});
let res = solver.solve(&mut sys).expect("should converge");
assert!(res.is_converged());
}
/// With max_frozen_iters = 0, the Jacobian is never reused.
/// The solver should behave identically to standard Newton.
#[test]
fn test_max_frozen_iters_zero_never_freezes() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_linear_targets(targets.clone());
let mut solver = NewtonConfig::default().with_jacobian_freezing(JacobianFreezingConfig {
max_frozen_iters: 0,
threshold: 0.1,
});
let res = solver.solve(&mut sys).expect("should converge");
assert!(res.is_converged());
assert_eq!(res.iterations, 1);
}
/// Run the cubic system with freezing and without, verify both converge.
/// This implicitly tests that auto-recompute kicks in when the frozen
/// Jacobian causes insufficient progress on the non-linear system.
#[test]
fn test_auto_recompute_on_divergence_trend() {
let targets = vec![1.0, 2.0];
// Without freezing (baseline)
let mut sys1 = build_system_with_cubic_targets(targets.clone());
let mut solver1 = NewtonConfig {
max_iterations: 200,
tolerance: 1e-6,
..Default::default()
};
let res1 = solver1.solve(&mut sys1).expect("baseline should converge");
// With freezing (aggressive: freeze up to 5 iters)
let mut sys2 = build_system_with_cubic_targets(targets.clone());
let mut solver2 = NewtonConfig {
max_iterations: 200,
tolerance: 1e-6,
..Default::default()
}
.with_jacobian_freezing(JacobianFreezingConfig {
max_frozen_iters: 5,
threshold: 0.05,
});
let res2 = solver2.solve(&mut sys2).expect("frozen should converge");
// Both should reach a sufficiently converged state
assert!(res1.is_converged());
assert!(res2.is_converged());
assert!(
res1.final_residual < 1e-6,
"Baseline residual should be below tolerance"
);
assert!(
res2.final_residual < 1e-6,
"Frozen residual should be below tolerance"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Edge cases
// ─────────────────────────────────────────────────────────────────────────────
/// Empty system with freezing enabled should just return InvalidSystem error.
#[test]
fn test_jacobian_freezing_empty_system() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig::default().with_jacobian_freezing(JacobianFreezingConfig {
max_frozen_iters: 3,
threshold: 0.1,
});
let result = solver.solve(&mut sys);
assert!(result.is_err(), "Empty system should return error");
}
/// Freezing with initial_state already at solution → converges in 0 iterations.
#[test]
fn test_jacobian_freezing_already_converged_at_initial_state() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_linear_targets(targets.clone());
let mut solver = NewtonConfig::default()
.with_initial_state(targets.clone())
.with_jacobian_freezing(JacobianFreezingConfig::default());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert_eq!(converged.iterations, 0, "Should be converged at initial state");
}