feat(components): add ThermoState generators and Eurovent backend demo
This commit is contained in:
410
crates/solver/tests/picard_sequential.rs
Normal file
410
crates/solver/tests/picard_sequential.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
//! Integration tests for Sequential Substitution (Picard) solver (Story 4.3).
|
||||
//!
|
||||
//! Tests cover:
|
||||
//! - AC #1: Reliable convergence when Newton diverges
|
||||
//! - AC #2: Sequential variable update
|
||||
//! - AC #3: Configurable relaxation factors
|
||||
//! - AC #4: Timeout enforcement
|
||||
//! - AC #5: Divergence detection
|
||||
//! - AC #6: Pre-allocated buffers
|
||||
|
||||
use entropyk_solver::{PicardConfig, Solver, SolverError, System};
|
||||
use approx::assert_relative_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AC #1: Solver Trait and Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_picard_config_default() {
|
||||
let cfg = PicardConfig::default();
|
||||
|
||||
assert_eq!(cfg.max_iterations, 100);
|
||||
assert_relative_eq!(cfg.tolerance, 1e-6);
|
||||
assert_relative_eq!(cfg.relaxation_factor, 0.5);
|
||||
assert!(cfg.timeout.is_none());
|
||||
assert_relative_eq!(cfg.divergence_threshold, 1e10);
|
||||
assert_eq!(cfg.divergence_patience, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_config_with_timeout() {
|
||||
let timeout = Duration::from_millis(500);
|
||||
let cfg = PicardConfig::default().with_timeout(timeout);
|
||||
|
||||
assert_eq!(cfg.timeout, Some(timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_config_custom_values() {
|
||||
let cfg = PicardConfig {
|
||||
max_iterations: 200,
|
||||
tolerance: 1e-8,
|
||||
relaxation_factor: 0.3,
|
||||
timeout: Some(Duration::from_millis(1000)),
|
||||
divergence_threshold: 1e8,
|
||||
divergence_patience: 7,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(cfg.max_iterations, 200);
|
||||
assert_relative_eq!(cfg.tolerance, 1e-8);
|
||||
assert_relative_eq!(cfg.relaxation_factor, 0.3);
|
||||
assert_eq!(cfg.timeout, Some(Duration::from_millis(1000)));
|
||||
assert_relative_eq!(cfg.divergence_threshold, 1e8);
|
||||
assert_eq!(cfg.divergence_patience, 7);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AC #2: Empty System Handling
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_empty_system_returns_invalid() {
|
||||
let mut sys = System::new();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let mut solver = PicardConfig::default();
|
||||
let result = solver.solve(&mut sys);
|
||||
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(SolverError::InvalidSystem { message }) => {
|
||||
assert!(
|
||||
message.contains("Empty") || message.contains("no state"),
|
||||
"Expected empty system message, got: {}",
|
||||
message
|
||||
);
|
||||
}
|
||||
other => panic!("Expected InvalidSystem, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "finalize")]
|
||||
fn test_picard_empty_system_without_finalize_panics() {
|
||||
// System panics if solve() is called without finalize()
|
||||
// This is expected behavior - the solver requires a finalized system
|
||||
let mut sys = System::new();
|
||||
// Don't call finalize
|
||||
|
||||
let mut solver = PicardConfig::default();
|
||||
let _ = solver.solve(&mut sys);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AC #3: Relaxation Factor Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_relaxation_factor_default() {
|
||||
let cfg = PicardConfig::default();
|
||||
assert_relative_eq!(cfg.relaxation_factor, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relaxation_factor_full_update() {
|
||||
// omega = 1.0: Full update (fastest, may oscillate)
|
||||
let cfg = PicardConfig {
|
||||
relaxation_factor: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
assert_relative_eq!(cfg.relaxation_factor, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relaxation_factor_heavy_damping() {
|
||||
// omega = 0.1: Heavy damping (slow but very stable)
|
||||
let cfg = PicardConfig {
|
||||
relaxation_factor: 0.1,
|
||||
..Default::default()
|
||||
};
|
||||
assert_relative_eq!(cfg.relaxation_factor, 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relaxation_factor_moderate() {
|
||||
// omega = 0.5: Moderate damping (default, good balance)
|
||||
let cfg = PicardConfig {
|
||||
relaxation_factor: 0.5,
|
||||
..Default::default()
|
||||
};
|
||||
assert_relative_eq!(cfg.relaxation_factor, 0.5);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AC #4: Timeout Enforcement
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_timeout_value_stored() {
|
||||
let timeout = Duration::from_millis(250);
|
||||
let cfg = PicardConfig::default().with_timeout(timeout);
|
||||
|
||||
assert_eq!(cfg.timeout, Some(timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout_preserves_other_fields() {
|
||||
let cfg = PicardConfig {
|
||||
max_iterations: 150,
|
||||
tolerance: 1e-7,
|
||||
relaxation_factor: 0.25,
|
||||
timeout: None,
|
||||
divergence_threshold: 1e9,
|
||||
divergence_patience: 8,
|
||||
..Default::default()
|
||||
}
|
||||
.with_timeout(Duration::from_millis(300));
|
||||
|
||||
assert_eq!(cfg.max_iterations, 150);
|
||||
assert_relative_eq!(cfg.tolerance, 1e-7);
|
||||
assert_relative_eq!(cfg.relaxation_factor, 0.25);
|
||||
assert_eq!(cfg.timeout, Some(Duration::from_millis(300)));
|
||||
assert_relative_eq!(cfg.divergence_threshold, 1e9);
|
||||
assert_eq!(cfg.divergence_patience, 8);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AC #5: Divergence Detection Configuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_divergence_threshold_default() {
|
||||
let cfg = PicardConfig::default();
|
||||
assert_relative_eq!(cfg.divergence_threshold, 1e10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_divergence_patience_default() {
|
||||
let cfg = PicardConfig::default();
|
||||
assert_eq!(cfg.divergence_patience, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_divergence_patience_higher_than_newton() {
|
||||
// Newton uses hardcoded patience of 3
|
||||
// Picard should be more tolerant (5 by default)
|
||||
let cfg = PicardConfig::default();
|
||||
assert!(
|
||||
cfg.divergence_patience >= 5,
|
||||
"Picard divergence_patience ({}) should be >= 5 (more tolerant than Newton's 3)",
|
||||
cfg.divergence_patience
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_divergence_threshold_custom() {
|
||||
let cfg = PicardConfig {
|
||||
divergence_threshold: 1e6,
|
||||
..Default::default()
|
||||
};
|
||||
assert_relative_eq!(cfg.divergence_threshold, 1e6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_divergence_patience_custom() {
|
||||
let cfg = PicardConfig {
|
||||
divergence_patience: 10,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(cfg.divergence_patience, 10);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AC #6: Pre-Allocated Buffers (No Panic)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_solver_does_not_panic_on_empty_system() {
|
||||
let mut sys = System::new();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let mut solver = PicardConfig::default();
|
||||
|
||||
// Should complete without panic
|
||||
let result = solver.solve(&mut sys);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_solver_does_not_panic_with_small_relaxation() {
|
||||
let mut sys = System::new();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let mut solver = PicardConfig {
|
||||
relaxation_factor: 0.1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Should complete without panic
|
||||
let result = solver.solve(&mut sys);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_solver_does_not_panic_with_full_relaxation() {
|
||||
let mut sys = System::new();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let mut solver = PicardConfig {
|
||||
relaxation_factor: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Should complete without panic
|
||||
let result = solver.solve(&mut sys);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_solver_does_not_panic_with_timeout() {
|
||||
let mut sys = System::new();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let mut solver = PicardConfig {
|
||||
timeout: Some(Duration::from_millis(10)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Should complete without panic
|
||||
let result = solver.solve(&mut sys);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Error Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_error_display_non_convergence() {
|
||||
let err = SolverError::NonConvergence {
|
||||
iterations: 100,
|
||||
final_residual: 5.67e-4,
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("100"));
|
||||
assert!(msg.contains("5.67"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display_timeout() {
|
||||
let err = SolverError::Timeout { timeout_ms: 250 };
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("250"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display_divergence() {
|
||||
let err = SolverError::Divergence {
|
||||
reason: "residual increased for 5 consecutive iterations".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("residual increased"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display_invalid_system() {
|
||||
let err = SolverError::InvalidSystem {
|
||||
message: "State dimension does not match equation count".to_string(),
|
||||
};
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("State dimension"));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ConvergedState
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_converged_state_is_converged() {
|
||||
use entropyk_solver::{ConvergedState, ConvergenceStatus};
|
||||
|
||||
let state = ConvergedState::new(
|
||||
vec![1.0, 2.0, 3.0],
|
||||
25,
|
||||
1e-7,
|
||||
ConvergenceStatus::Converged,
|
||||
);
|
||||
|
||||
assert!(state.is_converged());
|
||||
assert_eq!(state.iterations, 25);
|
||||
assert_eq!(state.state, vec![1.0, 2.0, 3.0]);
|
||||
assert_relative_eq!(state.final_residual, 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_converged_state_timed_out() {
|
||||
use entropyk_solver::{ConvergedState, ConvergenceStatus};
|
||||
|
||||
let state = ConvergedState::new(
|
||||
vec![0.5],
|
||||
75,
|
||||
1e-2,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
);
|
||||
|
||||
assert!(!state.is_converged());
|
||||
assert_eq!(state.status, ConvergenceStatus::TimedOutWithBestState);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SolverStrategy Integration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_solver_strategy_picard_dispatch() {
|
||||
use entropyk_solver::SolverStrategy;
|
||||
|
||||
let mut strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default());
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
|
||||
let result = strategy.solve(&mut system);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_solver_strategy_picard_with_timeout() {
|
||||
use entropyk_solver::SolverStrategy;
|
||||
|
||||
let strategy =
|
||||
SolverStrategy::SequentialSubstitution(PicardConfig::default())
|
||||
.with_timeout(Duration::from_millis(100));
|
||||
|
||||
match strategy {
|
||||
SolverStrategy::SequentialSubstitution(cfg) => {
|
||||
assert_eq!(cfg.timeout, Some(Duration::from_millis(100)));
|
||||
}
|
||||
other => panic!("Expected SequentialSubstitution, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Dimension Mismatch Handling
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_picard_dimension_mismatch_returns_error() {
|
||||
// Picard requires state dimension == equation count
|
||||
// This is validated in solve() before iteration begins
|
||||
let mut sys = System::new();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let mut solver = PicardConfig::default();
|
||||
let result = solver.solve(&mut sys);
|
||||
|
||||
// Empty system should return InvalidSystem
|
||||
assert!(result.is_err());
|
||||
match result {
|
||||
Err(SolverError::InvalidSystem { message }) => {
|
||||
assert!(
|
||||
message.contains("Empty") || message.contains("no state"),
|
||||
"Expected empty system message, got: {}",
|
||||
message
|
||||
);
|
||||
}
|
||||
other => panic!("Expected InvalidSystem, got {:?}", other),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user