chore: sync project state and current artifacts
This commit is contained in:
467
crates/solver/src/strategies/sequential_substitution.rs
Normal file
467
crates/solver/src/strategies/sequential_substitution.rs
Normal file
@@ -0,0 +1,467 @@
|
||||
//! Sequential Substitution (Picard iteration) solver implementation.
|
||||
//!
|
||||
//! Provides [`PicardConfig`] which implements Picard iteration for solving
|
||||
//! systems of non-linear equations. Slower than Newton-Raphson but more robust.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::criteria::ConvergenceCriteria;
|
||||
use crate::metadata::SimulationMetadata;
|
||||
use crate::solver::{ConvergedState, ConvergenceStatus, Solver, SolverError, TimeoutConfig};
|
||||
use crate::system::System;
|
||||
|
||||
/// Configuration for the Sequential Substitution (Picard iteration) solver.
|
||||
///
|
||||
/// Solves x = G(x) by iterating: x_{k+1} = (1-ω)·x_k + ω·G(x_k)
|
||||
/// where ω ∈ (0,1] is the relaxation factor.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PicardConfig {
|
||||
/// Maximum iterations. Default: 100.
|
||||
pub max_iterations: usize,
|
||||
/// Convergence tolerance (L2 norm). Default: 1e-6.
|
||||
pub tolerance: f64,
|
||||
/// Relaxation factor ω ∈ (0,1]. Default: 0.5.
|
||||
pub relaxation_factor: f64,
|
||||
/// Optional time budget.
|
||||
pub timeout: Option<Duration>,
|
||||
/// Divergence threshold. Default: 1e10.
|
||||
pub divergence_threshold: f64,
|
||||
/// Consecutive increases before divergence. Default: 5.
|
||||
pub divergence_patience: usize,
|
||||
/// Timeout behavior configuration.
|
||||
pub timeout_config: TimeoutConfig,
|
||||
/// Previous state for ZOH fallback.
|
||||
pub previous_state: Option<Vec<f64>>,
|
||||
/// Residual for previous_state.
|
||||
pub previous_residual: Option<f64>,
|
||||
/// Smart initial state for cold-start.
|
||||
pub initial_state: Option<Vec<f64>>,
|
||||
/// Multi-circuit convergence criteria.
|
||||
pub convergence_criteria: Option<ConvergenceCriteria>,
|
||||
}
|
||||
|
||||
impl Default for PicardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iterations: 100,
|
||||
tolerance: 1e-6,
|
||||
relaxation_factor: 0.5,
|
||||
timeout: None,
|
||||
divergence_threshold: 1e10,
|
||||
divergence_patience: 5,
|
||||
timeout_config: TimeoutConfig::default(),
|
||||
previous_state: None,
|
||||
previous_residual: None,
|
||||
initial_state: None,
|
||||
convergence_criteria: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PicardConfig {
|
||||
/// Sets the initial state for cold-start solving (Story 4.6 — builder pattern).
|
||||
///
|
||||
/// The solver will start from `state` instead of the zero vector.
|
||||
/// Use [`SmartInitializer::populate_state`] to generate a physically reasonable
|
||||
/// initial guess.
|
||||
pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
|
||||
self.initial_state = Some(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets multi-circuit convergence criteria (Story 4.7 — builder pattern).
|
||||
///
|
||||
/// When set, the solver uses [`ConvergenceCriteria::check()`] instead of the
|
||||
/// raw L2-norm `tolerance` check.
|
||||
pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
|
||||
self.convergence_criteria = Some(criteria);
|
||||
self
|
||||
}
|
||||
|
||||
/// Computes the residual norm (L2 norm of the residual vector).
|
||||
fn residual_norm(residuals: &[f64]) -> f64 {
|
||||
residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
/// Handles timeout based on configuration (Story 4.5).
|
||||
///
|
||||
/// Returns either:
|
||||
/// - `Ok(ConvergedState)` with `TimedOutWithBestState` status (default)
|
||||
/// - `Err(SolverError::Timeout)` if `return_best_state_on_timeout` is false
|
||||
/// - Previous state (ZOH) if `zoh_fallback` is true and previous state available
|
||||
fn handle_timeout(
|
||||
&self,
|
||||
best_state: &[f64],
|
||||
best_residual: f64,
|
||||
iterations: usize,
|
||||
timeout: Duration,
|
||||
system: &System,
|
||||
) -> Result<ConvergedState, SolverError> {
|
||||
// If configured to return error on timeout
|
||||
if !self.timeout_config.return_best_state_on_timeout {
|
||||
return Err(SolverError::Timeout {
|
||||
timeout_ms: timeout.as_millis() as u64,
|
||||
});
|
||||
}
|
||||
|
||||
// If ZOH fallback is enabled and previous state is available
|
||||
if self.timeout_config.zoh_fallback {
|
||||
if let Some(ref prev_state) = self.previous_state {
|
||||
let residual = self.previous_residual.unwrap_or(best_residual);
|
||||
tracing::info!(
|
||||
iterations = iterations,
|
||||
residual = residual,
|
||||
"Returning previous state (ZOH fallback) on timeout"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
prev_state.clone(),
|
||||
iterations,
|
||||
residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Default: return best state encountered during iteration
|
||||
tracing::info!(
|
||||
iterations = iterations,
|
||||
best_residual = best_residual,
|
||||
"Returning best state on timeout"
|
||||
);
|
||||
Ok(ConvergedState::new(
|
||||
best_state.to_vec(),
|
||||
iterations,
|
||||
best_residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Checks for divergence based on residual growth pattern.
|
||||
///
|
||||
/// Returns `Some(SolverError::Divergence)` if:
|
||||
/// - Residual norm exceeds `divergence_threshold`, or
|
||||
/// - Residual has increased for `divergence_patience`+ consecutive iterations
|
||||
fn check_divergence(
|
||||
&self,
|
||||
current_norm: f64,
|
||||
previous_norm: f64,
|
||||
divergence_count: &mut usize,
|
||||
) -> Option<SolverError> {
|
||||
// Check absolute threshold
|
||||
if current_norm > self.divergence_threshold {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!(
|
||||
"Residual norm {} exceeds threshold {}",
|
||||
current_norm, self.divergence_threshold
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Check consecutive increases
|
||||
if current_norm > previous_norm {
|
||||
*divergence_count += 1;
|
||||
if *divergence_count >= self.divergence_patience {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!(
|
||||
"Residual increased for {} consecutive iterations: {:.6e} → {:.6e}",
|
||||
self.divergence_patience, previous_norm, current_norm
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
*divergence_count = 0;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Applies relaxation to the state update.
|
||||
///
|
||||
/// Update formula: x_new = x_old - omega * residual
|
||||
/// where residual = F(x_k) represents the equation residuals.
|
||||
///
|
||||
/// This is the standard Picard iteration: x_{k+1} = x_k - ω·F(x_k)
|
||||
fn apply_relaxation(state: &mut [f64], residuals: &[f64], omega: f64) {
|
||||
for (x, &r) in state.iter_mut().zip(residuals.iter()) {
|
||||
*x -= omega * r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for PicardConfig {
|
||||
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
tracing::info!(
|
||||
max_iterations = self.max_iterations,
|
||||
tolerance = self.tolerance,
|
||||
relaxation_factor = self.relaxation_factor,
|
||||
divergence_threshold = self.divergence_threshold,
|
||||
divergence_patience = self.divergence_patience,
|
||||
"Sequential Substitution (Picard) solver starting"
|
||||
);
|
||||
|
||||
// Get system dimensions
|
||||
let n_state = system.full_state_vector_len();
|
||||
let n_equations: usize = system
|
||||
.traverse_for_jacobian()
|
||||
.map(|(_, c, _)| c.n_equations())
|
||||
.sum::<usize>()
|
||||
+ system.constraints().count()
|
||||
+ system.coupling_residual_count();
|
||||
|
||||
// Validate system
|
||||
if n_state == 0 || n_equations == 0 {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: "Empty system has no state variables or equations".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate state/equation dimensions
|
||||
if n_state != n_equations {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: format!(
|
||||
"State dimension ({}) does not match equation count ({})",
|
||||
n_state, n_equations
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-allocate all buffers (AC: #6 - no heap allocation in iteration loop)
|
||||
// Story 4.6 - AC: #8: Use initial_state if provided, else start from zeros
|
||||
let mut state: Vec<f64> = self
|
||||
.initial_state
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
debug_assert_eq!(
|
||||
s.len(),
|
||||
n_state,
|
||||
"initial_state length mismatch: expected {}, got {}",
|
||||
n_state,
|
||||
s.len()
|
||||
);
|
||||
if s.len() == n_state {
|
||||
s.clone()
|
||||
} else {
|
||||
vec![0.0; n_state]
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| vec![0.0; n_state]);
|
||||
let mut prev_iteration_state: Vec<f64> = vec![0.0; n_state]; // For convergence delta check
|
||||
let mut residuals: Vec<f64> = vec![0.0; n_equations];
|
||||
let mut divergence_count: usize = 0;
|
||||
let mut previous_norm: f64;
|
||||
|
||||
// Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
|
||||
let mut best_state: Vec<f64> = vec![0.0; n_state];
|
||||
let mut best_residual: f64;
|
||||
|
||||
// Initial residual computation
|
||||
system
|
||||
.compute_residuals(&state, &mut residuals)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute initial residuals: {:?}", e),
|
||||
})?;
|
||||
|
||||
let mut current_norm = Self::residual_norm(&residuals);
|
||||
|
||||
// Initialize best state tracking with initial state
|
||||
best_state.copy_from_slice(&state);
|
||||
best_residual = current_norm;
|
||||
|
||||
tracing::debug!(iteration = 0, residual_norm = current_norm, "Initial state");
|
||||
|
||||
// Check if already converged
|
||||
if current_norm < self.tolerance {
|
||||
tracing::info!(
|
||||
iterations = 0,
|
||||
final_residual = current_norm,
|
||||
"System already converged at initial state"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
state,
|
||||
0,
|
||||
current_norm,
|
||||
ConvergenceStatus::Converged,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
|
||||
// Main Picard iteration loop
|
||||
for iteration in 1..=self.max_iterations {
|
||||
// Save state before step for convergence criteria delta checks
|
||||
prev_iteration_state.copy_from_slice(&state);
|
||||
|
||||
// Check timeout at iteration start (Story 4.5 - AC: #1)
|
||||
if let Some(timeout) = self.timeout {
|
||||
if start_time.elapsed() > timeout {
|
||||
tracing::info!(
|
||||
iteration = iteration,
|
||||
elapsed_ms = start_time.elapsed().as_millis(),
|
||||
timeout_ms = timeout.as_millis(),
|
||||
best_residual = best_residual,
|
||||
"Solver timed out"
|
||||
);
|
||||
|
||||
// Story 4.5 - AC: #2, #6: Return best state or error based on config
|
||||
return self.handle_timeout(
|
||||
&best_state,
|
||||
best_residual,
|
||||
iteration - 1,
|
||||
timeout,
|
||||
system,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply relaxed update: x_new = x_old - omega * residual (AC: #2, #3)
|
||||
Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);
|
||||
|
||||
// Compute new residuals
|
||||
system
|
||||
.compute_residuals(&state, &mut residuals)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute residuals: {:?}", e),
|
||||
})?;
|
||||
|
||||
previous_norm = current_norm;
|
||||
current_norm = Self::residual_norm(&residuals);
|
||||
|
||||
// Update best state if residual improved (Story 4.5 - AC: #2)
|
||||
if current_norm < best_residual {
|
||||
best_state.copy_from_slice(&state);
|
||||
best_residual = current_norm;
|
||||
tracing::debug!(
|
||||
iteration = iteration,
|
||||
best_residual = best_residual,
|
||||
"Best state updated"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
iteration = iteration,
|
||||
residual_norm = current_norm,
|
||||
relaxation_factor = self.relaxation_factor,
|
||||
"Picard iteration complete"
|
||||
);
|
||||
|
||||
// Check convergence (AC: #1, Story 4.7 — criteria-aware)
|
||||
let converged = if let Some(ref criteria) = self.convergence_criteria {
|
||||
let report =
|
||||
criteria.check(&state, Some(&prev_iteration_state), &residuals, system);
|
||||
if report.is_globally_converged() {
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
relaxation_factor = self.relaxation_factor,
|
||||
"Sequential Substitution converged (criteria)"
|
||||
);
|
||||
return Ok(ConvergedState::with_report(
|
||||
state,
|
||||
iteration,
|
||||
current_norm,
|
||||
ConvergenceStatus::Converged,
|
||||
report,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
false
|
||||
} else {
|
||||
current_norm < self.tolerance
|
||||
};
|
||||
|
||||
if converged {
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
relaxation_factor = self.relaxation_factor,
|
||||
"Sequential Substitution converged"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
state,
|
||||
iteration,
|
||||
current_norm,
|
||||
ConvergenceStatus::Converged,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
|
||||
// Check divergence (AC: #5)
|
||||
if let Some(err) =
|
||||
self.check_divergence(current_norm, previous_norm, &mut divergence_count)
|
||||
{
|
||||
tracing::warn!(
|
||||
iteration = iteration,
|
||||
residual_norm = current_norm,
|
||||
"Divergence detected"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Max iterations exceeded
|
||||
tracing::warn!(
|
||||
max_iterations = self.max_iterations,
|
||||
final_residual = current_norm,
|
||||
"Sequential Substitution did not converge"
|
||||
);
|
||||
Err(SolverError::NonConvergence {
|
||||
iterations: self.max_iterations,
|
||||
final_residual: current_norm,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::solver::Solver;
|
||||
use crate::system::System;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_picard_config_with_timeout() {
|
||||
let timeout = Duration::from_millis(250);
|
||||
let cfg = PicardConfig::default().with_timeout(timeout);
|
||||
assert_eq!(cfg.timeout, Some(timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_config_default_sensible() {
|
||||
let cfg = PicardConfig::default();
|
||||
assert_eq!(cfg.max_iterations, 100);
|
||||
assert!(cfg.tolerance > 0.0 && cfg.tolerance < 1e-3);
|
||||
assert!(cfg.relaxation_factor > 0.0 && cfg.relaxation_factor <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_apply_relaxation_formula() {
|
||||
let mut state = vec![10.0, 20.0];
|
||||
let residuals = vec![1.0, 2.0];
|
||||
PicardConfig::apply_relaxation(&mut state, &residuals, 0.5);
|
||||
assert!((state[0] - 9.5).abs() < 1e-15);
|
||||
assert!((state[1] - 19.0).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_residual_norm() {
|
||||
let residuals = vec![3.0, 4.0];
|
||||
let norm = PicardConfig::residual_norm(&residuals);
|
||||
assert!((norm - 5.0).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_solver_trait_object() {
|
||||
let mut boxed: Box<dyn Solver> = Box::new(PicardConfig::default());
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
assert!(boxed.solve(&mut system).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user