Files
Entropyk/crates/solver/tests/smart_initializer.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

311 lines
13 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, Temperature};
use entropyk_solver::{
solver::{FallbackSolver, NewtonConfig, PicardConfig, Solver, SolverError},
system::DEFAULT_MASS_FLOW_SEED_KG_S,
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> {
// CM1.3: per-edge state is (ṁ, P, h). Equations i=0..n target state[i+1]
// (P and h slots). The last equation pins the mass-flow (state[0]) to the
// default seed so the system stays square with 3 unknowns per edge.
for (i, &t) in self.targets.iter().enumerate() {
residuals[i] = state[i + 1] - t;
}
residuals[self.targets.len()] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S;
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, 1.0);
}
// Mass-flow equation: ∂r_ṁ/∂state[0] = 1
jacobian.add_entry(self.targets.len(), 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
self.targets.len() + 1
}
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() {
// 1 edge × (ṁ, P, h); seed ṁ so the placeholder mass-flow closure is satisfied.
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(vec![
DEFAULT_MASS_FLOW_SEED_KG_S,
targets[0],
targets[1],
]);
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(vec![
DEFAULT_MASS_FLOW_SEED_KG_S,
targets[0],
targets[1],
]);
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(vec![
DEFAULT_MASS_FLOW_SEED_KG_S,
targets[0],
targets[1],
]);
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.
// 1 edge × (ṁ, P, h): seed ṁ then the two scaled targets for P, h.
let smart_state: Vec<f64> = std::iter::once(DEFAULT_MASS_FLOW_SEED_KG_S)
.chain(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");
// CM1.4: 2-edge linear chain → 1 series branch + 2×2 P,h = 5 state vars.
// State layout: [ṁ_branch, P_e0, h_e0, P_e1, h_e1]
assert_eq!(state.len(), 5);
// All edges share 1 ṁ slot (same series branch) → seeded to the mass-flow seed.
// All edges in single circuit → P_evap used for all.
assert_relative_eq!(state[0], DEFAULT_MASS_FLOW_SEED_KG_S, max_relative = 1e-9); // ṁ branch
assert_relative_eq!(state[1], p_evap.to_pascals(), max_relative = 1e-9); // P edge 0
assert_relative_eq!(state[2], h_default.to_joules_per_kg(), max_relative = 1e-9); // h edge 0
assert_relative_eq!(state[3], p_evap.to_pascals(), max_relative = 1e-9); // P edge 1
assert_relative_eq!(state[4], h_default.to_joules_per_kg(), max_relative = 1e-9);
// h edge 1
}
/// A mismatched `initial_state` length is rejected cleanly (zero-panic).
///
/// Previously this aborted via `debug_assert` in debug builds and silently fell
/// back to zeros in release builds (solving a different problem). The contract is
/// now uniform across build profiles and solvers: a wrong-length initial state
/// returns `SolverError::InvalidSystem` rather than panicking or guessing.
#[test]
fn test_initial_state_length_mismatch_is_rejected() {
// System has 3 state entries (1 edge × (ṁ, P, h))
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let wrong_state = vec![1.0, 2.0]; // length 2, system needs 3
let mut solver = NewtonConfig::default().with_initial_state(wrong_state);
let result = solver.solve(&mut sys);
assert!(
matches!(result, Err(SolverError::InvalidSystem { .. })),
"expected InvalidSystem for a length mismatch, got {result:?}"
);
}