Files
Entropyk/crates/solver/tests/timeout_budgeted_solving.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

431 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 {
Self {
a: [[2.0, 1.0], [1.0, 2.0]],
b: [3.0, 3.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.2: Picard's positional update is misaligned by the ṁ-front /
// closure-back layout for this synthetic 2×2, so seed it at the analytical
// solution (ṁ=seed, P=1, h=1). CM1.3 restores alignment with real residuals.
initial_state: Some(vec![DEFAULT_MASS_FLOW_SEED_KG_S, 1.0, 1.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.2: seed Picard at the analytical solution (ṁ=seed, P=1, h=1) — the
// synthetic ṁ-closure misaligns Picard's positional update until CM1.3.
initial_state: Some(vec![DEFAULT_MASS_FLOW_SEED_KG_S, 1.0, 1.0]),
..Default::default()
};
let result = solver.solve(&mut system);
match result {
Ok(_) | Err(SolverError::Timeout { .. }) | Err(SolverError::NonConvergence { .. }) => {}
Err(other) => panic!("Unexpected error: {:?}", other),
}
}