chore: sync project state and current artifacts

This commit is contained in:
Sepehr
2026-02-22 23:27:31 +01:00
parent 1b6415776e
commit dd77089b22
232 changed files with 37056 additions and 4296 deletions

View 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());
}
}