//! 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, /// 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>, /// Residual for previous_state. pub previous_residual: Option, /// Smart initial state for cold-start. pub initial_state: Option>, /// Multi-circuit convergence criteria. pub convergence_criteria: Option, } 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) -> 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::().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 { // 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 { // 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 { 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::() + 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 = 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 = vec![0.0; n_state]; // For convergence delta check let mut residuals: Vec = 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 = 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 = Box::new(PicardConfig::default()); let mut system = System::new(); system.finalize().unwrap(); assert!(boxed.solve(&mut system).is_err()); } }