421 lines
15 KiB
Rust
421 lines
15 KiB
Rust
//! 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, SystemState,
|
|
};
|
|
use entropyk_solver::solver::{
|
|
ConvergenceStatus, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
|
|
SolverError, TimeoutConfig,
|
|
};
|
|
use entropyk_solver::system::System;
|
|
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: &SystemState,
|
|
residuals: &mut ResidualVector,
|
|
) -> Result<(), ComponentError> {
|
|
residuals[0] = self.a[0][0] * state[0] + self.a[0][1] * state[1] - self.b[0];
|
|
residuals[1] = self.a[1][0] * state[0] + self.a[1][1] * state[1] - self.b[1];
|
|
Ok(())
|
|
}
|
|
|
|
fn jacobian_entries(
|
|
&self,
|
|
_state: &SystemState,
|
|
jacobian: &mut JacobianBuilder,
|
|
) -> Result<(), ComponentError> {
|
|
jacobian.add_entry(0, 0, self.a[0][0]);
|
|
jacobian.add_entry(0, 1, self.a[0][1]);
|
|
jacobian.add_entry(1, 0, self.a[1][0]);
|
|
jacobian.add_entry(1, 1, self.a[1][1]);
|
|
Ok(())
|
|
}
|
|
|
|
fn n_equations(&self) -> usize {
|
|
2
|
|
}
|
|
|
|
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![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(), 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_zoh_fallback_picard() {
|
|
let mut system = create_test_system(Box::new(LinearSystem2x2::well_conditioned()));
|
|
let previous_state = vec![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![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,
|
|
},
|
|
..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,
|
|
..Default::default()
|
|
};
|
|
|
|
let result = solver.solve(&mut system);
|
|
match result {
|
|
Ok(_) | Err(SolverError::Timeout { .. }) | Err(SolverError::NonConvergence { .. }) => {}
|
|
Err(other) => panic!("Unexpected error: {:?}", other),
|
|
}
|
|
}
|