Files
Entropyk/crates/solver/tests/timeout_budgeted_solving.rs
sepehr 5bd180b5b8
Some checks failed
CI / check (push) Has been cancelled
Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 16:35:31 +02:00

435 lines
16 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.5: Time-Budgeted Solving
//!
//! Tests the timeout behavior with best-state return:
//! - Timeout returns best state instead of error
//! - Best state is the lowest residual encountered
//! - ZOH (Zero-Order Hold) fallback for HIL scenarios
//! - Configurable timeout behavior
//! - Timeout across fallback switches preserves best state
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice};
use entropyk_solver::solver::{
ConvergenceStatus, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
SolverError, TimeoutConfig,
};
use entropyk_solver::system::{System, DEFAULT_MASS_FLOW_SEED_KG_S};
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// Mock Components for Testing
// ─────────────────────────────────────────────────────────────────────────────
/// A 2x2 linear system: r = A * x - b
struct LinearSystem2x2 {
a: [[f64; 2]; 2],
b: [f64; 2],
}
impl LinearSystem2x2 {
fn well_conditioned() -> Self {
// CM1.3: the solver clamps pressure (state[1]) to >= 10_000 Pa. Choose
// the linear system so that the analytical solution lands at the bound
// (P = h = 10_000), keeping Newton convergence exact in one iteration.
Self {
a: [[2.0, 1.0], [1.0, 2.0]],
b: [30_000.0, 30_000.0],
}
}
}
impl Component for LinearSystem2x2 {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// CM1.3: per-edge state is (ṁ, P, h); the 2×2 system acts on (P, h) at
// global indices 1 and 2. The third equation pins ṁ (state[0]) to the
// default seed so the system is square (3 equations, 3 unknowns).
residuals[0] = self.a[0][0] * state[1] + self.a[0][1] * state[2] - self.b[0];
residuals[1] = self.a[1][0] * state[1] + self.a[1][1] * state[2] - self.b[1];
residuals[2] = state[0] - DEFAULT_MASS_FLOW_SEED_KG_S;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 1, self.a[0][0]);
jacobian.add_entry(0, 2, self.a[0][1]);
jacobian.add_entry(1, 1, self.a[1][0]);
jacobian.add_entry(1, 2, self.a[1][1]);
jacobian.add_entry(2, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
3
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
fn create_test_system(component: Box<dyn Component>) -> System {
let mut system = System::new();
let n0 = system.add_component(component);
system.add_edge(n0, n0).unwrap();
system.finalize().unwrap();
system
}
// ─────────────────────────────────────────────────────────────────────────────
// TimeoutConfig Tests (AC: #6)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_timeout_config_defaults() {
let config = TimeoutConfig::default();
assert!(config.return_best_state_on_timeout);
assert!(!config.zoh_fallback);
}
#[test]
fn test_timeout_config_zoh_enabled() {
let config = TimeoutConfig {
return_best_state_on_timeout: true,
zoh_fallback: true,
};
assert!(config.zoh_fallback);
}
#[test]
fn test_timeout_config_return_error_on_timeout() {
let config = TimeoutConfig {
return_best_state_on_timeout: false,
zoh_fallback: false,
};
assert!(!config.return_best_state_on_timeout);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC: #1, #2 - Timeout Returns Best State
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_timeout_returns_best_state_not_error() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let timeout = Duration::from_nanos(1);
let mut solver = NewtonConfig {
timeout: Some(timeout),
max_iterations: 10000,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: true,
zoh_fallback: false,
},
..Default::default()
};
let result = solver.solve(&mut system);
match result {
Ok(state) => {
assert!(
state.status == ConvergenceStatus::Converged
|| state.status == ConvergenceStatus::TimedOutWithBestState
);
}
Err(SolverError::Timeout { .. }) => {}
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
#[test]
fn test_best_state_is_lowest_residual() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let timeout = Duration::from_micros(100);
let mut solver = NewtonConfig {
timeout: Some(timeout),
max_iterations: 10000,
timeout_config: TimeoutConfig::default(),
..Default::default()
};
let result = solver.solve(&mut system);
if let Ok(state) = result {
assert!(state.final_residual.is_finite());
assert!(state.final_residual >= 0.0);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// AC: #3 - ZOH Fallback
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_zoh_fallback_returns_previous_state() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let previous_state = vec![DEFAULT_MASS_FLOW_SEED_KG_S, 1.0, 2.0];
let timeout = Duration::from_nanos(1);
let mut solver = NewtonConfig {
timeout: Some(timeout),
max_iterations: 10000,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: true,
zoh_fallback: true,
},
previous_state: Some(previous_state.clone()),
..Default::default()
};
let result = solver.solve(&mut system);
if let Ok(state) = result {
if state.status == ConvergenceStatus::TimedOutWithBestState {
assert_eq!(state.state, previous_state);
}
}
}
#[test]
fn test_zoh_fallback_ignored_without_previous_state() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let timeout = Duration::from_nanos(1);
let mut solver = NewtonConfig {
timeout: Some(timeout),
max_iterations: 10000,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: true,
zoh_fallback: true,
},
previous_state: None,
..Default::default()
};
let result = solver.solve(&mut system);
if let Ok(state) = result {
if state.status == ConvergenceStatus::TimedOutWithBestState {
assert_eq!(state.state.len(), 3);
}
}
}
#[test]
fn test_zoh_fallback_picard() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let previous_state = vec![DEFAULT_MASS_FLOW_SEED_KG_S, 5.0, 10.0];
let timeout = Duration::from_nanos(1);
let mut solver = PicardConfig {
timeout: Some(timeout),
max_iterations: 10000,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: true,
zoh_fallback: true,
},
previous_state: Some(previous_state.clone()),
..Default::default()
};
let result = solver.solve(&mut system);
if let Ok(state) = result {
if state.status == ConvergenceStatus::TimedOutWithBestState {
assert_eq!(state.state, previous_state);
}
}
}
#[test]
fn test_zoh_fallback_uses_previous_residual() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let previous_state = vec![DEFAULT_MASS_FLOW_SEED_KG_S, 1.0, 2.0];
let previous_residual = 1e-4;
let timeout = Duration::from_nanos(1);
let mut solver = NewtonConfig {
timeout: Some(timeout),
max_iterations: 10000,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: true,
zoh_fallback: true,
},
previous_state: Some(previous_state.clone()),
previous_residual: Some(previous_residual),
..Default::default()
};
let result = solver.solve(&mut system);
if let Ok(state) = result {
if state.status == ConvergenceStatus::TimedOutWithBestState {
assert_eq!(state.state, previous_state);
assert!((state.final_residual - previous_residual).abs() < 1e-10);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// AC: #6 - return_best_state_on_timeout = false
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_timeout_returns_error_when_configured() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let timeout = Duration::from_millis(1);
let mut solver = NewtonConfig {
timeout: Some(timeout),
max_iterations: 10000,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: false,
zoh_fallback: false,
},
..Default::default()
};
let result = solver.solve(&mut system);
match result {
Err(SolverError::Timeout { .. }) | Ok(_) => {}
Err(other) => panic!("Expected Timeout or Ok, got {:?}", other),
}
}
#[test]
fn test_picard_timeout_returns_error_when_configured() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let timeout = Duration::from_millis(1);
let mut solver = PicardConfig {
timeout: Some(timeout),
max_iterations: 10000,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: false,
zoh_fallback: false,
},
// CM1.3: keep the Picard seed at the analytical solution so the test
// focuses on timeout/pre-allocation wiring, not Picard convergence.
// Pressure must respect the 10_000 Pa lower bound, so the solution is
// (ṁ=seed, P=10_000, h=10_000).
initial_state: Some(vec![DEFAULT_MASS_FLOW_SEED_KG_S, 10_000.0, 10_000.0]),
..Default::default()
};
let result = solver.solve(&mut system);
match result {
Err(SolverError::Timeout { .. }) | Ok(_) => {}
Err(other) => panic!("Expected Timeout or Ok, got {:?}", other),
}
}
// ─────────────────────────────────────────────────────────────────────────────
// AC: #4 - Timeout Across Fallback Switches
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_timeout_across_fallback_switches_preserves_best_state() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let timeout = Duration::from_millis(10);
let mut solver = FallbackSolver::new(FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 2,
..Default::default()
})
.with_timeout(timeout)
.with_newton_config(NewtonConfig {
max_iterations: 500,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: true,
zoh_fallback: false,
},
..Default::default()
})
.with_picard_config(PicardConfig {
max_iterations: 500,
timeout_config: TimeoutConfig {
return_best_state_on_timeout: true,
zoh_fallback: false,
},
..Default::default()
});
let result = solver.solve(&mut system);
match result {
Ok(state) => {
assert!(
state.status == ConvergenceStatus::Converged
|| state.status == ConvergenceStatus::TimedOutWithBestState
);
assert!(state.final_residual.is_finite());
}
Err(SolverError::Timeout { .. }) => {}
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
#[test]
fn test_fallback_solver_total_timeout() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let timeout = Duration::from_millis(5);
let mut solver = FallbackSolver::default_solver()
.with_timeout(timeout)
.with_newton_config(NewtonConfig {
max_iterations: 10000,
..Default::default()
})
.with_picard_config(PicardConfig {
max_iterations: 10000,
..Default::default()
});
let start = std::time::Instant::now();
let result = solver.solve(&mut system);
let elapsed = start.elapsed();
if result.is_err()
|| matches!(result, Ok(ref s) if s.status == ConvergenceStatus::TimedOutWithBestState)
{
assert!(
elapsed < timeout + Duration::from_millis(100),
"Total solve time should respect timeout budget. Elapsed: {:?}, Timeout: {:?}",
elapsed,
timeout
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Pre-allocation Tests (AC: #5)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_newton_config_best_state_preallocated() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let mut solver = NewtonConfig {
timeout: Some(Duration::from_millis(100)),
max_iterations: 10,
..Default::default()
};
let result = solver.solve(&mut system);
assert!(result.is_ok() || matches!(result, Err(SolverError::Timeout { .. })));
}
#[test]
fn test_picard_config_best_state_preallocated() {
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
let mut solver = PicardConfig {
timeout: Some(Duration::from_millis(100)),
max_iterations: 10,
// CM1.3: seed Picard at the analytical solution (ṁ=seed, P=10_000, h=10_000)
// so the test targets pre-allocation wiring and not Picard convergence.
initial_state: Some(vec![DEFAULT_MASS_FLOW_SEED_KG_S, 10_000.0, 10_000.0]),
..Default::default()
};
let result = solver.solve(&mut system);
match result {
Ok(_) | Err(SolverError::Timeout { .. }) | Err(SolverError::NonConvergence { .. }) => {}
Err(other) => panic!("Unexpected error: {:?}", other),
}
}