feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

View File

@@ -0,0 +1,267 @@
//! 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 entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, SystemState};
use entropyk_core::{Enthalpy, Pressure, Temperature};
use entropyk_solver::{
solver::{FallbackSolver, NewtonConfig, PicardConfig, Solver},
InitializerConfig, SmartInitializer, System,
};
use approx::assert_relative_eq;
// ─────────────────────────────────────────────────────────────────────────────
// 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: &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] {
&[]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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
}
}