Entropyk/crates/solver/tests/smart_initializer.rs

298 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Integration tests for Story 4.6: Smart Initialization Heuristic (AC: #8)
//!
//! Tests cover:
//! - AC #8: Integration with FallbackSolver via `with_initial_state`
//! - Cold-start convergence: SmartInitializer → FallbackSolver
//! - `initial_state` respected by NewtonConfig and PicardConfig
//! - `with_initial_state` builder on FallbackSolver delegates to both sub-solvers
use approx::assert_relative_eq;
use entropyk_components::{
Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Enthalpy, Pressure, Temperature};
use entropyk_solver::{
solver::{FallbackSolver, NewtonConfig, PicardConfig, Solver},
InitializerConfig, SmartInitializer, System,
};
// ─────────────────────────────────────────────────────────────────────────────
// Mock Components for Testing
// ─────────────────────────────────────────────────────────────────────────────
/// A simple linear component whose residual is r_i = x_i - target_i.
/// The solution is x = target. Used to verify initial_state is copied correctly.
struct LinearTargetSystem {
/// Target values (solution)
targets: Vec<f64>,
}
impl LinearTargetSystem {
fn new(targets: Vec<f64>) -> Self {
Self { targets }
}
}
impl Component for LinearTargetSystem {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for (i, &t) in self.targets.iter().enumerate() {
residuals[i] = state[i] - t;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
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] {
&[]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
fn build_system_with_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
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #8: Integration with Solver — initial_state accepted via builders
// ─────────────────────────────────────────────────────────────────────────────
/// AC #8 — `NewtonConfig::with_initial_state` starts from provided state.
///
/// We build a 2-entry system where target = [3e5, 4e5].
/// Starting from zeros → needs to close the gap.
/// Starting from the exact solution → should converge in 0 additional iterations
/// (already converged at initial check).
#[test]
fn test_newton_with_initial_state_converges_at_target() {
// 2-entry state (1 edge × 2 entries: P, h)
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = NewtonConfig::default().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
// Started exactly at solution → 0 iterations needed
assert_eq!(
converged.iterations, 0,
"Should converge at initial state (0 iterations)"
);
assert!(converged.final_residual < 1e-6);
}
/// AC #8 — `PicardConfig::with_initial_state` starts from provided state.
#[test]
fn test_picard_with_initial_state_converges_at_target() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = PicardConfig::default().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert_eq!(
converged.iterations, 0,
"Should converge at initial state (0 iterations)"
);
assert!(converged.final_residual < 1e-6);
}
/// AC #8 — `FallbackSolver::with_initial_state` delegates to both newton and picard.
#[test]
fn test_fallback_solver_with_initial_state_delegates() {
let state = vec![300_000.0, 400_000.0];
let solver = FallbackSolver::default_solver().with_initial_state(state.clone());
// Verify both sub-solvers received the initial state
assert_eq!(
solver.newton_config.initial_state.as_deref(),
Some(state.as_slice()),
"NewtonConfig should have the initial state"
);
assert_eq!(
solver.picard_config.initial_state.as_deref(),
Some(state.as_slice()),
"PicardConfig should have the initial state"
);
}
/// AC #8 — `FallbackSolver::with_initial_state` causes early convergence at exact solution.
#[test]
fn test_fallback_solver_with_initial_state_at_solution() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = FallbackSolver::default_solver().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert_eq!(
converged.iterations, 0,
"Should converge immediately at initial state"
);
}
/// AC #8 — Smart initial state reduces iterations vs. zero initial state.
///
/// We use a system where the solution is far from zero (large P, h values).
/// Newton from zero must close a large gap; Newton from SmartInitializer's output
/// starts close and should converge in fewer iterations.
#[test]
fn test_smart_initializer_reduces_iterations_vs_zero_start() {
// System solution: P = 300_000, h = 400_000
let targets = vec![300_000.0_f64, 400_000.0_f64];
// Run 1: from zeros
let mut sys_zero = build_system_with_targets(targets.clone());
let mut solver_zero = NewtonConfig::default();
let result_zero = solver_zero
.solve(&mut sys_zero)
.expect("zero-start should converge");
// Run 2: from smart initial state (we directly provide the values as an approximation)
// Use 95% of target as "smart" initial — simulating a near-correct heuristic
let smart_state: Vec<f64> = targets.iter().map(|&t| t * 0.95).collect();
let mut sys_smart = build_system_with_targets(targets.clone());
let mut solver_smart = NewtonConfig::default().with_initial_state(smart_state);
let result_smart = solver_smart
.solve(&mut sys_smart)
.expect("smart-start should converge");
// Smart start should converge at least as fast (same or fewer iterations)
// For a linear system, Newton always converges in 1 step regardless of start,
// so both should use ≤ 1 iteration and achieve tolerance
assert!(
result_zero.final_residual < 1e-6,
"Zero start should converge to tolerance"
);
assert!(
result_smart.final_residual < 1e-6,
"Smart start should converge to tolerance"
);
assert!(
result_smart.iterations <= result_zero.iterations,
"Smart start ({} iters) should not need more iterations than zero start ({} iters)",
result_smart.iterations,
result_zero.iterations
);
}
// ─────────────────────────────────────────────────────────────────────────────
// SmartInitializer API — cold-start pressure estimation
// ─────────────────────────────────────────────────────────────────────────────
/// AC #8 — SmartInitializer produces pressures and populate_state works end-to-end.
///
/// Full integration: estimate pressures → populate state → verify no allocation.
#[test]
fn test_cold_start_estimate_then_populate() {
let init = SmartInitializer::new(InitializerConfig {
fluid: entropyk_components::port::FluidId::new("R134a"),
dt_approach: 5.0,
});
let t_source = Temperature::from_celsius(5.0);
let t_sink = Temperature::from_celsius(40.0);
let (p_evap, p_cond) = init
.estimate_pressures(t_source, t_sink)
.expect("R134a estimation should succeed");
// Both pressures should be physically reasonable
assert!(p_evap.to_bar() > 0.5, "P_evap should be > 0.5 bar");
assert!(
p_cond.to_bar() > p_evap.to_bar(),
"P_cond should exceed P_evap"
);
assert!(
p_cond.to_bar() < 50.0,
"P_cond should be < 50 bar (not supercritical)"
);
// Build a 2-edge system and populate state
let mut sys = System::new();
let n0 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
let n1 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
let n2 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n2).unwrap();
sys.finalize().unwrap();
let h_default = Enthalpy::from_joules_per_kg(420_000.0);
let mut state = vec![0.0f64; sys.state_vector_len()]; // pre-allocated, no allocation in populate_state
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.expect("populate_state should succeed");
assert_eq!(state.len(), 4); // 2 edges × [P, h]
// All edges in single circuit → P_evap used for all
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
assert_relative_eq!(state[2], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// AC #8 — Verify initial_state length mismatch falls back gracefully (doesn't panic).
///
/// In release mode the solver silently falls back to zeros; in debug mode
/// debug_assert fires but we can't test that here (it would abort). We verify
/// the release-mode behavior: a mismatched initial_state causes fallback to zeros
/// and the solver still converges.
#[test]
fn test_initial_state_length_mismatch_fallback() {
// System has 2 state entries (1 edge × 2)
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
// Provide wrong-length initial state (3 instead of 2)
// In release mode: solver falls back to zeros, still converges
// In debug mode: debug_assert panics — we skip this test in debug
#[cfg(not(debug_assertions))]
{
let wrong_state = vec![1.0, 2.0, 3.0]; // length 3, system needs 2
let mut solver = NewtonConfig::default().with_initial_state(wrong_state);
let result = solver.solve(&mut sys);
// Should still converge (fell back to zeros)
assert!(
result.is_ok(),
"Should converge even with mismatched initial_state in release mode"
);
}
#[cfg(debug_assertions)]
{
// In debug mode, skip this test (debug_assert would abort)
let _ = (sys, targets); // suppress unused variable warnings
}
}