Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,7 +31,7 @@ use crate::solver::{
|
||||
};
|
||||
use crate::system::System;
|
||||
|
||||
use super::{NewtonConfig, PicardConfig};
|
||||
use super::{HomotopyConfig, NewtonConfig, PicardConfig};
|
||||
|
||||
/// Configuration for the intelligent fallback solver.
|
||||
///
|
||||
@@ -143,7 +143,7 @@ impl FallbackState {
|
||||
self.best_residual = Some(residual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Record a solver switch event (Story 7.4)
|
||||
fn record_switch(
|
||||
&mut self,
|
||||
@@ -188,15 +188,29 @@ pub struct FallbackSolver {
|
||||
pub newton_config: NewtonConfig,
|
||||
/// Sequential Substitution (Picard) configuration.
|
||||
pub picard_config: PicardConfig,
|
||||
/// Optional Newton-homotopy continuation used as a last-resort recovery.
|
||||
///
|
||||
/// When set, the homotopy solver is invoked if both Newton and Picard fail
|
||||
/// (divergence or non-convergence) from the cold start. This mirrors IPM
|
||||
/// BOLT's escalating initialization cascade (`GLBL` → `iGLBL` → `PRVS` →
|
||||
/// `iPRVS`): cheap solvers first, robust continuation only when needed.
|
||||
/// `None` (default) preserves the original Newton↔Picard-only behaviour.
|
||||
pub homotopy_config: Option<HomotopyConfig>,
|
||||
}
|
||||
|
||||
impl FallbackSolver {
|
||||
/// Creates a new fallback solver with the given configuration.
|
||||
///
|
||||
/// The Picard fallback stage is configured with Anderson acceleration
|
||||
/// (depth 3) by default, which converges the fixed-point iteration
|
||||
/// super-linearly and typically halves the fallback iteration count without
|
||||
/// affecting robustness. Override via [`Self::with_picard_config`].
|
||||
pub fn new(config: FallbackConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
newton_config: NewtonConfig::default(),
|
||||
picard_config: PicardConfig::default(),
|
||||
picard_config: PicardConfig::default().with_anderson(3),
|
||||
homotopy_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,13 +231,27 @@ impl FallbackSolver {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables Newton-homotopy continuation as a last-resort recovery stage.
|
||||
///
|
||||
/// If both Newton and Picard fail from the cold start, the homotopy solver
|
||||
/// is invoked to walk in from `λ = 0` to `λ = 1` (see [`HomotopyConfig`]).
|
||||
/// When the supplied config has no `initial_state`, the fallback solver's
|
||||
/// Newton initial state is reused so all stages share the same cold start.
|
||||
pub fn with_homotopy(mut self, config: HomotopyConfig) -> Self {
|
||||
self.homotopy_config = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the initial state for cold-start solving (Story 4.6 — builder pattern).
|
||||
///
|
||||
/// Delegates to both `newton_config` and `picard_config` so the initial state
|
||||
/// is used regardless of which solver is active in the fallback loop.
|
||||
/// Delegates to `newton_config`, `picard_config`, and the optional homotopy
|
||||
/// stage so the initial state is used regardless of which solver runs.
|
||||
pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
|
||||
self.newton_config.initial_state = Some(state.clone());
|
||||
self.picard_config.initial_state = Some(state);
|
||||
self.picard_config.initial_state = Some(state.clone());
|
||||
if let Some(ref mut h) = self.homotopy_config {
|
||||
h.initial_state = Some(state);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
@@ -251,10 +279,10 @@ impl FallbackSolver {
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<ConvergedState, SolverError> {
|
||||
let mut state = FallbackState::new();
|
||||
|
||||
|
||||
// Verbose mode setup
|
||||
let verbose_enabled = self.config.verbose_config.enabled
|
||||
&& self.config.verbose_config.is_any_enabled();
|
||||
let verbose_enabled =
|
||||
self.config.verbose_config.enabled && self.config.verbose_config.is_any_enabled();
|
||||
let mut diagnostics = if verbose_enabled {
|
||||
Some(ConvergenceDiagnostics::with_capacity(100))
|
||||
} else {
|
||||
@@ -264,7 +292,7 @@ impl FallbackSolver {
|
||||
// Pre-configure solver configs once
|
||||
let mut newton_cfg = self.newton_config.clone();
|
||||
let mut picard_cfg = self.picard_config.clone();
|
||||
|
||||
|
||||
// Propagate verbose config to child solvers
|
||||
newton_cfg.verbose_config = self.config.verbose_config.clone();
|
||||
picard_cfg.verbose_config = self.config.verbose_config.clone();
|
||||
@@ -301,17 +329,18 @@ impl FallbackSolver {
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = state.total_iterations;
|
||||
diag.final_residual = converged.final_residual;
|
||||
diag.best_residual = state.best_residual.unwrap_or(converged.final_residual);
|
||||
diag.best_residual =
|
||||
state.best_residual.unwrap_or(converged.final_residual);
|
||||
diag.converged = true;
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.final_solver = Some(state.current_solver.into());
|
||||
diag.solver_switches = state.switch_events.clone();
|
||||
|
||||
|
||||
// Merge iteration history from child solver if available
|
||||
if let Some(ref child_diag) = converged.diagnostics {
|
||||
diag.iteration_history = child_diag.iteration_history.clone();
|
||||
}
|
||||
|
||||
|
||||
if self.config.verbose_config.log_residuals {
|
||||
tracing::info!("{}", diag.summary());
|
||||
}
|
||||
@@ -327,287 +356,385 @@ impl FallbackSolver {
|
||||
switch_count = state.switch_count,
|
||||
"Fallback solver converged"
|
||||
);
|
||||
|
||||
|
||||
// Return with diagnostics if verbose mode enabled
|
||||
return Ok(if let Some(d) = diagnostics {
|
||||
ConvergedState { diagnostics: Some(d), ..converged }
|
||||
} else { converged });
|
||||
ConvergedState {
|
||||
diagnostics: Some(d),
|
||||
..converged
|
||||
}
|
||||
} else {
|
||||
converged
|
||||
});
|
||||
}
|
||||
Err(SolverError::Timeout { timeout_ms }) => {
|
||||
// Story 4.5 - AC: #4: Return best state on timeout if available
|
||||
if let (Some(best_state), Some(best_residual)) =
|
||||
(state.best_state.clone(), state.best_residual)
|
||||
{
|
||||
tracing::info!(
|
||||
best_residual = best_residual,
|
||||
"Returning best state across all solver invocations on timeout"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
best_state,
|
||||
state.total_iterations,
|
||||
best_residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
return Err(SolverError::Timeout { timeout_ms });
|
||||
}
|
||||
Err(SolverError::Divergence { ref reason }) => {
|
||||
// Handle divergence based on current solver and state
|
||||
if !self.config.fallback_enabled {
|
||||
tracing::info!(
|
||||
solver = match state.current_solver {
|
||||
CurrentSolver::Newton => "NewtonRaphson",
|
||||
CurrentSolver::Picard => "Picard",
|
||||
},
|
||||
reason = reason,
|
||||
"Divergence detected, fallback disabled"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
match state.current_solver {
|
||||
CurrentSolver::Newton => {
|
||||
// Get residual from error context (use best known)
|
||||
let residual_at_switch = state.best_residual.unwrap_or(f64::MAX);
|
||||
|
||||
// Newton diverged - switch to Picard (stay there permanently after max switches)
|
||||
if state.switch_count >= self.config.max_fallback_switches {
|
||||
// Max switches reached - commit to Picard permanently
|
||||
state.committed_to_picard = true;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::Divergence,
|
||||
residual_at_switch,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled && self.config.verbose_config.log_solver_switches {
|
||||
tracing::info!(
|
||||
from = "NewtonRaphson",
|
||||
to = "Picard",
|
||||
reason = "divergence",
|
||||
switch_count = state.switch_count,
|
||||
residual = residual_at_switch,
|
||||
"Solver switch (max switches reached)"
|
||||
);
|
||||
}
|
||||
|
||||
Err(err) => {
|
||||
let child_diagnostics = err.diagnostics().cloned();
|
||||
match err.without_diagnostics() {
|
||||
SolverError::Timeout { timeout_ms } => {
|
||||
// Story 4.5 - AC: #4: Return best state on timeout if available
|
||||
if let (Some(best_state), Some(best_residual)) =
|
||||
(state.best_state.clone(), state.best_residual)
|
||||
{
|
||||
tracing::info!(
|
||||
best_residual = best_residual,
|
||||
"Returning best state across all solver invocations on timeout"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
best_state,
|
||||
state.total_iterations,
|
||||
best_residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
return Err(SolverError::Timeout { timeout_ms }
|
||||
.with_optional_diagnostics(child_diagnostics));
|
||||
}
|
||||
SolverError::Divergence { reason } => {
|
||||
// Handle divergence based on current solver and state
|
||||
if !self.config.fallback_enabled {
|
||||
tracing::info!(
|
||||
solver = match state.current_solver {
|
||||
CurrentSolver::Newton => "NewtonRaphson",
|
||||
CurrentSolver::Picard => "Picard",
|
||||
},
|
||||
reason = reason,
|
||||
"Divergence detected, fallback disabled"
|
||||
);
|
||||
return Err(SolverError::Divergence { reason }
|
||||
.with_optional_diagnostics(child_diagnostics));
|
||||
}
|
||||
|
||||
match state.current_solver {
|
||||
CurrentSolver::Newton => {
|
||||
// Get residual from error context (use best known)
|
||||
let residual_at_switch =
|
||||
state.best_residual.unwrap_or(f64::MAX);
|
||||
|
||||
// Newton diverged - switch to Picard (stay there permanently after max switches)
|
||||
if state.switch_count >= self.config.max_fallback_switches {
|
||||
// Max switches reached - commit to Picard permanently
|
||||
state.committed_to_picard = true;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::Divergence,
|
||||
residual_at_switch,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled
|
||||
&& self.config.verbose_config.log_solver_switches
|
||||
{
|
||||
tracing::info!(
|
||||
from = "NewtonRaphson",
|
||||
to = "Picard",
|
||||
reason = "divergence",
|
||||
switch_count = state.switch_count,
|
||||
residual = residual_at_switch,
|
||||
"Solver switch (max switches reached)"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
max_switches = self.config.max_fallback_switches,
|
||||
"Max switches reached, committing to Picard permanently"
|
||||
);
|
||||
} else {
|
||||
// Switch to Picard
|
||||
state.switch_count += 1;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::Divergence,
|
||||
residual_at_switch,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled && self.config.verbose_config.log_solver_switches {
|
||||
tracing::info!(
|
||||
from = "NewtonRaphson",
|
||||
to = "Picard",
|
||||
reason = "divergence",
|
||||
switch_count = state.switch_count,
|
||||
residual = residual_at_switch,
|
||||
"Solver switch"
|
||||
);
|
||||
} else {
|
||||
// Switch to Picard
|
||||
state.switch_count += 1;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::Divergence,
|
||||
residual_at_switch,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled
|
||||
&& self.config.verbose_config.log_solver_switches
|
||||
{
|
||||
tracing::info!(
|
||||
from = "NewtonRaphson",
|
||||
to = "Picard",
|
||||
reason = "divergence",
|
||||
switch_count = state.switch_count,
|
||||
residual = residual_at_switch,
|
||||
"Solver switch"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
switch_count = state.switch_count,
|
||||
reason = reason,
|
||||
"Newton diverged, switching to Picard"
|
||||
);
|
||||
}
|
||||
// Continue loop with Picard
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
switch_count = state.switch_count,
|
||||
reason = reason,
|
||||
"Newton diverged, switching to Picard"
|
||||
);
|
||||
}
|
||||
// Continue loop with Picard
|
||||
}
|
||||
CurrentSolver::Picard => {
|
||||
// Picard diverged - if we were trying Newton again, commit to Picard permanently
|
||||
if state.switch_count > 0 && !state.committed_to_picard {
|
||||
state.committed_to_picard = true;
|
||||
tracing::info!(
|
||||
CurrentSolver::Picard => {
|
||||
// Picard diverged - if we were trying Newton again, commit to Picard permanently
|
||||
if state.switch_count > 0 && !state.committed_to_picard {
|
||||
state.committed_to_picard = true;
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
reason = reason,
|
||||
"Newton re-diverged after return from Picard, staying on Picard permanently"
|
||||
);
|
||||
// Stay on Picard and try again
|
||||
} else {
|
||||
// Picard diverged with no return attempt - no more fallbacks available
|
||||
tracing::warn!(
|
||||
reason = reason,
|
||||
"Picard diverged, no more fallbacks available"
|
||||
);
|
||||
return result;
|
||||
// Stay on Picard and try again
|
||||
} else {
|
||||
// Picard diverged with no return attempt - no more fallbacks available
|
||||
tracing::warn!(
|
||||
reason = reason,
|
||||
"Picard diverged, no more fallbacks available"
|
||||
);
|
||||
return Err(SolverError::Divergence { reason }
|
||||
.with_optional_diagnostics(child_diagnostics));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
}) => {
|
||||
state.total_iterations += iterations;
|
||||
|
||||
// Non-convergence: check if we should try the other solver
|
||||
if !self.config.fallback_enabled {
|
||||
return Err(SolverError::NonConvergence {
|
||||
SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
});
|
||||
}
|
||||
} => {
|
||||
state.total_iterations += iterations;
|
||||
|
||||
match state.current_solver {
|
||||
CurrentSolver::Newton => {
|
||||
// Newton didn't converge - try Picard
|
||||
if state.switch_count >= self.config.max_fallback_switches {
|
||||
// Max switches reached - commit to Picard permanently
|
||||
state.committed_to_picard = true;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::SlowConvergence,
|
||||
// Non-convergence: check if we should try the other solver
|
||||
if !self.config.fallback_enabled {
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled && self.config.verbose_config.log_solver_switches {
|
||||
tracing::info!(
|
||||
from = "NewtonRaphson",
|
||||
to = "Picard",
|
||||
reason = "slow_convergence",
|
||||
switch_count = state.switch_count,
|
||||
residual = final_residual,
|
||||
"Solver switch (max switches reached)"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
.with_optional_diagnostics(child_diagnostics));
|
||||
}
|
||||
|
||||
match state.current_solver {
|
||||
CurrentSolver::Newton => {
|
||||
// Newton didn't converge - try Picard
|
||||
if state.switch_count >= self.config.max_fallback_switches {
|
||||
// Max switches reached - commit to Picard permanently
|
||||
state.committed_to_picard = true;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::SlowConvergence,
|
||||
final_residual,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled
|
||||
&& self.config.verbose_config.log_solver_switches
|
||||
{
|
||||
tracing::info!(
|
||||
from = "NewtonRaphson",
|
||||
to = "Picard",
|
||||
reason = "slow_convergence",
|
||||
switch_count = state.switch_count,
|
||||
residual = final_residual,
|
||||
"Solver switch (max switches reached)"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
"Max switches reached, committing to Picard permanently"
|
||||
);
|
||||
} else {
|
||||
state.switch_count += 1;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::SlowConvergence,
|
||||
final_residual,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled && self.config.verbose_config.log_solver_switches {
|
||||
tracing::info!(
|
||||
from = "NewtonRaphson",
|
||||
to = "Picard",
|
||||
reason = "slow_convergence",
|
||||
switch_count = state.switch_count,
|
||||
residual = final_residual,
|
||||
"Solver switch"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
iterations = iterations,
|
||||
final_residual = final_residual,
|
||||
"Newton did not converge, switching to Picard"
|
||||
);
|
||||
}
|
||||
// Continue loop with Picard
|
||||
}
|
||||
CurrentSolver::Picard => {
|
||||
// Picard didn't converge - check if we should try Newton
|
||||
if state.committed_to_picard
|
||||
|| state.switch_count >= self.config.max_fallback_switches
|
||||
{
|
||||
tracing::info!(
|
||||
iterations = iterations,
|
||||
final_residual = final_residual,
|
||||
"Picard did not converge, no more fallbacks"
|
||||
);
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
state.switch_count += 1;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
|
||||
// Check if residual is low enough to try Newton
|
||||
if final_residual < self.config.return_to_newton_threshold {
|
||||
state.switch_count += 1;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Newton;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::ReturnToNewton,
|
||||
final_residual,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled && self.config.verbose_config.log_solver_switches {
|
||||
tracing::info!(
|
||||
from = "Picard",
|
||||
to = "NewtonRaphson",
|
||||
reason = "return_to_newton",
|
||||
switch_count = state.switch_count,
|
||||
residual = final_residual,
|
||||
threshold = self.config.return_to_newton_threshold,
|
||||
"Solver switch (Picard stabilized)"
|
||||
);
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::SlowConvergence,
|
||||
final_residual,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled
|
||||
&& self.config.verbose_config.log_solver_switches
|
||||
{
|
||||
tracing::info!(
|
||||
from = "NewtonRaphson",
|
||||
to = "Picard",
|
||||
reason = "slow_convergence",
|
||||
switch_count = state.switch_count,
|
||||
residual = final_residual,
|
||||
"Solver switch"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
iterations = iterations,
|
||||
final_residual = final_residual,
|
||||
"Newton did not converge, switching to Picard"
|
||||
);
|
||||
}
|
||||
// Continue loop with Picard
|
||||
}
|
||||
CurrentSolver::Picard => {
|
||||
// Picard didn't converge - check if we should try Newton
|
||||
if state.committed_to_picard
|
||||
|| state.switch_count >= self.config.max_fallback_switches
|
||||
{
|
||||
tracing::info!(
|
||||
iterations = iterations,
|
||||
final_residual = final_residual,
|
||||
"Picard did not converge, no more fallbacks"
|
||||
);
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
}
|
||||
.with_optional_diagnostics(child_diagnostics));
|
||||
}
|
||||
|
||||
// Check if residual is low enough to try Newton
|
||||
if final_residual < self.config.return_to_newton_threshold {
|
||||
state.switch_count += 1;
|
||||
let prev_solver = state.current_solver;
|
||||
state.current_solver = CurrentSolver::Newton;
|
||||
|
||||
// Record switch event
|
||||
state.record_switch(
|
||||
prev_solver,
|
||||
state.current_solver,
|
||||
SwitchReason::ReturnToNewton,
|
||||
final_residual,
|
||||
);
|
||||
|
||||
// Verbose logging
|
||||
if verbose_enabled
|
||||
&& self.config.verbose_config.log_solver_switches
|
||||
{
|
||||
tracing::info!(
|
||||
from = "Picard",
|
||||
to = "NewtonRaphson",
|
||||
reason = "return_to_newton",
|
||||
switch_count = state.switch_count,
|
||||
residual = final_residual,
|
||||
threshold = self.config.return_to_newton_threshold,
|
||||
"Solver switch (Picard stabilized)"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
final_residual = final_residual,
|
||||
threshold = self.config.return_to_newton_threshold,
|
||||
"Picard stabilized, attempting Newton return"
|
||||
);
|
||||
// Continue loop with Newton
|
||||
} else {
|
||||
// Stay on Picard and keep trying
|
||||
tracing::debug!(
|
||||
final_residual = final_residual,
|
||||
threshold = self.config.return_to_newton_threshold,
|
||||
"Picard not yet stabilized, aborting"
|
||||
);
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
}
|
||||
.with_optional_diagnostics(child_diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
final_residual = final_residual,
|
||||
threshold = self.config.return_to_newton_threshold,
|
||||
"Picard stabilized, attempting Newton return"
|
||||
);
|
||||
// Continue loop with Newton
|
||||
} else {
|
||||
// Stay on Picard and keep trying
|
||||
tracing::debug!(
|
||||
final_residual = final_residual,
|
||||
threshold = self.config.return_to_newton_threshold,
|
||||
"Picard not yet stabilized, aborting"
|
||||
);
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
});
|
||||
}
|
||||
}
|
||||
other => {
|
||||
// InvalidSystem or other errors - propagate immediately
|
||||
return Err(other.with_optional_diagnostics(child_diagnostics));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(other) => {
|
||||
// InvalidSystem or other errors - propagate immediately
|
||||
return Err(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts Newton-homotopy continuation as a last-resort recovery after the
|
||||
/// primary Newton/Picard stages have failed.
|
||||
///
|
||||
/// Returns the homotopy result on success; otherwise returns the *primary*
|
||||
/// error (the homotopy failure is logged but not surfaced, since the primary
|
||||
/// error is the more actionable diagnostic). Structural `InvalidSystem`
|
||||
/// errors are never retried — they indicate a malformed model, not a hard
|
||||
/// cold start.
|
||||
fn try_homotopy_recovery(
|
||||
&self,
|
||||
system: &mut System,
|
||||
primary_err: SolverError,
|
||||
remaining: Option<Duration>,
|
||||
) -> Result<ConvergedState, SolverError> {
|
||||
if matches!(primary_err.base_error(), SolverError::InvalidSystem { .. }) {
|
||||
return Err(primary_err);
|
||||
}
|
||||
|
||||
let Some(mut homotopy) = self.homotopy_config.clone() else {
|
||||
return Err(primary_err);
|
||||
};
|
||||
|
||||
// Share the cold start with the primary solvers unless explicitly set.
|
||||
if homotopy.initial_state.is_none() {
|
||||
homotopy.initial_state = self.newton_config.initial_state.clone();
|
||||
}
|
||||
// Inherit the remaining global time budget if the stage has none.
|
||||
if homotopy.timeout.is_none() {
|
||||
homotopy.timeout = remaining;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
error = %primary_err,
|
||||
"Primary solvers failed; attempting Newton-homotopy continuation as last resort"
|
||||
);
|
||||
|
||||
match homotopy.solve(system) {
|
||||
Ok(converged) => {
|
||||
tracing::info!(
|
||||
iterations = converged.iterations,
|
||||
final_residual = converged.final_residual,
|
||||
"Homotopy continuation recovered convergence"
|
||||
);
|
||||
Ok(converged)
|
||||
}
|
||||
Err(homotopy_err) => {
|
||||
let primary_diagnostics = primary_err.diagnostics().cloned();
|
||||
let homotopy_diagnostics = homotopy_err.diagnostics().cloned();
|
||||
let diagnostics = match (primary_diagnostics, homotopy_diagnostics) {
|
||||
(Some(primary), Some(homotopy)) => {
|
||||
if homotopy.iterations >= primary.iterations {
|
||||
Some(homotopy)
|
||||
} else {
|
||||
Some(primary)
|
||||
}
|
||||
}
|
||||
(Some(primary), None) => Some(primary),
|
||||
(None, Some(homotopy)) => Some(homotopy),
|
||||
(None, None) => None,
|
||||
};
|
||||
tracing::warn!(
|
||||
error = %homotopy_err,
|
||||
"Homotopy continuation also failed; returning primary error"
|
||||
);
|
||||
Err(primary_err
|
||||
.without_diagnostics()
|
||||
.with_optional_diagnostics(diagnostics))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -622,20 +749,32 @@ impl Solver for FallbackSolver {
|
||||
fallback_enabled = self.config.fallback_enabled,
|
||||
return_to_newton_threshold = self.config.return_to_newton_threshold,
|
||||
max_fallback_switches = self.config.max_fallback_switches,
|
||||
homotopy_recovery = self.homotopy_config.is_some(),
|
||||
"Fallback solver starting"
|
||||
);
|
||||
|
||||
if self.config.fallback_enabled {
|
||||
let primary = if self.config.fallback_enabled {
|
||||
self.solve_with_fallback(system, start_time, timeout)
|
||||
} else {
|
||||
// Fallback disabled - run pure Newton
|
||||
self.newton_config.solve(system)
|
||||
};
|
||||
|
||||
match primary {
|
||||
Ok(converged) => Ok(converged),
|
||||
Err(primary_err) => {
|
||||
let remaining = timeout.map(|t| t.saturating_sub(start_time.elapsed()));
|
||||
self.try_homotopy_recovery(system, primary_err, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.newton_config.timeout = Some(timeout);
|
||||
self.picard_config.timeout = Some(timeout);
|
||||
if let Some(ref mut h) = self.homotopy_config {
|
||||
h.timeout = Some(timeout);
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -684,4 +823,60 @@ mod tests {
|
||||
system.finalize().unwrap();
|
||||
assert!(boxed.solve(&mut system).is_err());
|
||||
}
|
||||
|
||||
// ── Homotopy last-resort recovery wiring ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_fallback_homotopy_disabled_by_default() {
|
||||
let solver = FallbackSolver::default_solver();
|
||||
assert!(solver.homotopy_config.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_with_homotopy_sets_config() {
|
||||
let solver = FallbackSolver::default_solver()
|
||||
.with_homotopy(HomotopyConfig::default().with_initial_steps(20));
|
||||
let h = solver
|
||||
.homotopy_config
|
||||
.expect("homotopy should be configured");
|
||||
assert_eq!(h.initial_steps, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_initial_state_propagates_to_homotopy() {
|
||||
let solver = FallbackSolver::default_solver()
|
||||
.with_homotopy(HomotopyConfig::default())
|
||||
.with_initial_state(vec![1.0, 2.0, 3.0]);
|
||||
assert_eq!(
|
||||
solver.newton_config.initial_state,
|
||||
Some(vec![1.0, 2.0, 3.0])
|
||||
);
|
||||
assert_eq!(
|
||||
solver.picard_config.initial_state,
|
||||
Some(vec![1.0, 2.0, 3.0])
|
||||
);
|
||||
assert_eq!(
|
||||
solver.homotopy_config.unwrap().initial_state,
|
||||
Some(vec![1.0, 2.0, 3.0])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_timeout_propagates_to_homotopy() {
|
||||
let timeout = Duration::from_millis(750);
|
||||
let solver = FallbackSolver::default_solver()
|
||||
.with_homotopy(HomotopyConfig::default())
|
||||
.with_timeout(timeout);
|
||||
assert_eq!(solver.homotopy_config.unwrap().timeout, Some(timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_system_not_retried_by_homotopy() {
|
||||
// An empty (degenerate) system yields InvalidSystem; the homotopy stage
|
||||
// must NOT retry it — a malformed model is not a hard cold start.
|
||||
let mut solver = FallbackSolver::default_solver().with_homotopy(HomotopyConfig::default());
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
assert!(solver.solve(&mut system).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
496
crates/solver/src/strategies/homotopy.rs
Normal file
496
crates/solver/src/strategies/homotopy.rs
Normal file
@@ -0,0 +1,496 @@
|
||||
//! Newton-homotopy continuation solver for robust cold starts.
|
||||
//!
|
||||
//! This module provides [`HomotopyConfig`], a globally-convergent continuation
|
||||
//! solver that improves on a naive cold start without requiring a database of
|
||||
//! previous solutions (the IPM BOLT `GLBL`/`iPRVS` approach) or any manual
|
||||
//! tuning.
|
||||
//!
|
||||
//! # The Newton homotopy
|
||||
//!
|
||||
//! Given the target system `F(x) = 0` and an arbitrary initial guess `x₀`, define
|
||||
//! the homotopy
|
||||
//!
|
||||
//! ```text
|
||||
//! H(x, λ) = F(x) − (1 − λ) · F(x₀), λ ∈ [0, 1]
|
||||
//! ```
|
||||
//!
|
||||
//! At `λ = 0`, `H(x₀, 0) = F(x₀) − F(x₀) = 0`, so the initial guess is an
|
||||
//! **exact** solution of the deformed system. At `λ = 1`, `H(x, 1) = F(x)`, the
|
||||
//! real system. The solver walks `λ` from 0 to 1, solving `H(·, λ) = 0` with an
|
||||
//! inner Newton iteration at each step and using the previous converged point as
|
||||
//! the next initial guess.
|
||||
//!
|
||||
//! # Why it reuses the analytic Jacobian unchanged
|
||||
//!
|
||||
//! The subtracted term `(1 − λ)·F(x₀)` is **constant in `x`**, so
|
||||
//!
|
||||
//! ```text
|
||||
//! ∂H/∂x = ∂F/∂x = J(x)
|
||||
//! ```
|
||||
//!
|
||||
//! The inner Newton step therefore uses the exact, component-wise analytic
|
||||
//! Jacobian assembled by [`System::assemble_jacobian`] — no finite differences
|
||||
//! and no changes to any component are required. This keeps Entropyk's
|
||||
//! structural advantage over finite-difference solvers (IPM eKINSOL) while adding
|
||||
//! cold-start robustness. A `use_numerical_jacobian` flag is provided for parity
|
||||
//! with [`crate::strategies::NewtonConfig`] when a component's analytic Jacobian
|
||||
//! is unavailable.
|
||||
//!
|
||||
//! # Adaptive step control
|
||||
//!
|
||||
//! The `λ` increment starts at `1 / initial_steps` and is **halved** whenever an
|
||||
//! inner Newton solve fails to converge, retrying from the last good `λ`. On
|
||||
//! success the increment is gently grown again. This predictor–corrector scheme
|
||||
//! automatically takes small steps through difficult regions (phase boundaries,
|
||||
//! stiff correlations) and large steps through easy ones.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::jacobian::JacobianMatrix;
|
||||
use crate::metadata::SimulationMetadata;
|
||||
use crate::solver::{
|
||||
apply_newton_step, dominant_residual, ConvergedState, ConvergenceDiagnostics,
|
||||
ConvergenceStatus, IterationDiagnostics, Solver, SolverError, SolverType,
|
||||
};
|
||||
use crate::system::System;
|
||||
use entropyk_components::JacobianBuilder;
|
||||
|
||||
/// Configuration for the Newton-homotopy continuation solver.
|
||||
///
|
||||
/// Solves `F(x) = 0` by continuation on the homotopy
|
||||
/// `H(x, λ) = F(x) − (1 − λ)·F(x₀)` from `λ = 0` (where `x₀` is exact) to
|
||||
/// `λ = 1` (the real system).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct HomotopyConfig {
|
||||
/// Initial number of `λ` subdivisions. The starting step is `1 / initial_steps`.
|
||||
/// Default: 10.
|
||||
pub initial_steps: usize,
|
||||
/// Maximum Newton iterations allowed for each inner `λ` solve. Default: 50.
|
||||
pub inner_max_iterations: usize,
|
||||
/// Convergence tolerance (L2 residual norm) for each inner `λ` solve.
|
||||
/// Default: 1e-8.
|
||||
pub inner_tolerance: f64,
|
||||
/// Final convergence tolerance (L2 residual norm) checked at `λ = 1`.
|
||||
/// Default: 1e-6.
|
||||
pub tolerance: f64,
|
||||
/// Smallest allowed `λ` increment before the solver gives up. Default: 1e-4.
|
||||
pub min_lambda_step: f64,
|
||||
/// Divergence guard: inner residual norm above this aborts the inner solve.
|
||||
/// Default: 1e12.
|
||||
pub divergence_threshold: f64,
|
||||
/// Use a finite-difference Jacobian instead of the analytic one. Default: false.
|
||||
pub use_numerical_jacobian: bool,
|
||||
/// Relative step for the finite-difference Jacobian. Default: 1e-5.
|
||||
pub numerical_epsilon: f64,
|
||||
/// Optional overall time budget.
|
||||
pub timeout: Option<Duration>,
|
||||
/// Initial guess `x₀`. When `None`, a zero vector is used.
|
||||
pub initial_state: Option<Vec<f64>>,
|
||||
}
|
||||
|
||||
impl Default for HomotopyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_steps: 10,
|
||||
inner_max_iterations: 50,
|
||||
inner_tolerance: 1e-8,
|
||||
tolerance: 1e-6,
|
||||
min_lambda_step: 1e-4,
|
||||
divergence_threshold: 1e12,
|
||||
use_numerical_jacobian: false,
|
||||
numerical_epsilon: 1e-5,
|
||||
timeout: None,
|
||||
initial_state: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HomotopyConfig {
|
||||
/// Sets the initial guess `x₀` for the continuation.
|
||||
pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
|
||||
self.initial_state = Some(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the initial number of `λ` subdivisions.
|
||||
pub fn with_initial_steps(mut self, steps: usize) -> Self {
|
||||
self.initial_steps = steps.max(1);
|
||||
self
|
||||
}
|
||||
|
||||
/// Selects the finite-difference Jacobian (analytic is the default).
|
||||
pub fn with_numerical_jacobian(mut self, enabled: bool) -> Self {
|
||||
self.use_numerical_jacobian = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// L2 norm of a residual vector.
|
||||
fn residual_norm(residuals: &[f64]) -> f64 {
|
||||
residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
fn failure_diagnostics(
|
||||
&self,
|
||||
iterations: usize,
|
||||
final_residual: f64,
|
||||
residuals: &[f64],
|
||||
elapsed_ms: u64,
|
||||
) -> Option<ConvergenceDiagnostics> {
|
||||
if iterations == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (max_residual_index, max_residual) = dominant_residual(residuals);
|
||||
let mut diagnostics = ConvergenceDiagnostics::with_capacity(1);
|
||||
diagnostics.iterations = iterations;
|
||||
diagnostics.final_residual = final_residual;
|
||||
diagnostics.best_residual = final_residual;
|
||||
diagnostics.converged = false;
|
||||
diagnostics.timing_ms = elapsed_ms;
|
||||
diagnostics.final_solver = Some(SolverType::Homotopy);
|
||||
diagnostics.push_iteration(IterationDiagnostics {
|
||||
iteration: iterations,
|
||||
residual_norm: final_residual,
|
||||
delta_norm: 0.0,
|
||||
alpha: Some(1.0),
|
||||
jacobian_frozen: false,
|
||||
jacobian_condition: None,
|
||||
max_residual_index,
|
||||
max_residual,
|
||||
});
|
||||
Some(diagnostics)
|
||||
}
|
||||
|
||||
/// Runs the inner Newton iteration for `H(x, λ) = F(x) − (1 − λ)·r0 = 0`.
|
||||
///
|
||||
/// Mutates `state` in place. Returns `Ok(iterations)` if the inner system
|
||||
/// converged below `inner_tolerance`, or `Err(())` if it diverged, the
|
||||
/// Jacobian was singular, or the iteration budget was exhausted. On failure
|
||||
/// the caller restores the previous good state.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn inner_newton(
|
||||
&self,
|
||||
system: &mut System,
|
||||
state: &mut [f64],
|
||||
r0: &[f64],
|
||||
lambda: f64,
|
||||
clipping_mask: &[Option<(f64, f64)>],
|
||||
residuals: &mut Vec<f64>,
|
||||
residuals_h: &mut Vec<f64>,
|
||||
jacobian: &mut JacobianMatrix,
|
||||
jacobian_builder: &mut JacobianBuilder,
|
||||
) -> Result<usize, ()> {
|
||||
let offset = 1.0 - lambda;
|
||||
|
||||
for k in 0..self.inner_max_iterations {
|
||||
// Evaluate F(x) and form the homotopy residual H = F − (1 − λ)·r0.
|
||||
if system.compute_residuals(state, residuals).is_err() {
|
||||
return Err(());
|
||||
}
|
||||
for i in 0..residuals.len() {
|
||||
residuals_h[i] = residuals[i] - offset * r0[i];
|
||||
}
|
||||
|
||||
let norm = Self::residual_norm(residuals_h.as_slice());
|
||||
if norm < self.inner_tolerance {
|
||||
return Ok(k);
|
||||
}
|
||||
if !norm.is_finite() || norm > self.divergence_threshold {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
// ∂H/∂x = ∂F/∂x, so the Jacobian of F is used unchanged.
|
||||
if self.use_numerical_jacobian {
|
||||
let eps = self.numerical_epsilon;
|
||||
let compute = |s: &[f64], r: &mut [f64]| {
|
||||
let s_vec = s.to_vec();
|
||||
let mut r_vec = vec![0.0; r.len()];
|
||||
let res = system.compute_residuals(&s_vec, &mut r_vec);
|
||||
r.copy_from_slice(&r_vec);
|
||||
res.map(|_| ()).map_err(|e| format!("{:?}", e))
|
||||
};
|
||||
match JacobianMatrix::numerical(compute, state, residuals.as_slice(), eps) {
|
||||
Ok(jm) => jacobian.as_matrix_mut().copy_from(jm.as_matrix()),
|
||||
Err(_) => return Err(()),
|
||||
}
|
||||
} else {
|
||||
jacobian_builder.clear();
|
||||
if system.assemble_jacobian(state, jacobian_builder).is_err() {
|
||||
return Err(());
|
||||
}
|
||||
jacobian.update_from_builder(jacobian_builder.entries());
|
||||
}
|
||||
|
||||
// Solve J·Δx = −H (the solve routine negates the supplied residual).
|
||||
let delta = match jacobian.solve(residuals_h.as_slice()) {
|
||||
Some(d) => d,
|
||||
None => return Err(()),
|
||||
};
|
||||
|
||||
apply_newton_step(state, &delta, clipping_mask, 1.0);
|
||||
}
|
||||
|
||||
// Final convergence check after the last step.
|
||||
if system.compute_residuals(state, residuals).is_err() {
|
||||
return Err(());
|
||||
}
|
||||
for i in 0..residuals.len() {
|
||||
residuals_h[i] = residuals[i] - offset * r0[i];
|
||||
}
|
||||
if Self::residual_norm(residuals_h.as_slice()) < self.inner_tolerance {
|
||||
Ok(self.inner_max_iterations)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for HomotopyConfig {
|
||||
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
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()
|
||||
+ 2 * system.saturated_controller_count()
|
||||
+ system.mass_flow_closure_count();
|
||||
|
||||
if n_state == 0 || n_equations == 0 {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: "Empty system has no state variables or equations".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Working buffers (allocated once, reused across every λ step).
|
||||
// A caller-supplied initial guess MUST match the system size: silently
|
||||
// substituting zeros would hide a caller bug behind an opaque later failure.
|
||||
let mut state: Vec<f64> = match self.initial_state.as_ref() {
|
||||
Some(s) if s.len() == n_state => s.clone(),
|
||||
Some(s) => {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: format!(
|
||||
"initial_state length {} does not match system state length {}",
|
||||
s.len(),
|
||||
n_state
|
||||
),
|
||||
});
|
||||
}
|
||||
None => vec![0.0; n_state],
|
||||
};
|
||||
let mut residuals = vec![0.0; n_equations];
|
||||
let mut residuals_h = vec![0.0; n_equations];
|
||||
let mut jacobian = JacobianMatrix::zeros(n_equations, n_state);
|
||||
let mut jacobian_builder = JacobianBuilder::new();
|
||||
let mut state_saved = vec![0.0; n_state];
|
||||
|
||||
let clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
|
||||
.map(|i| system.get_bounds_for_state_index(i))
|
||||
.collect();
|
||||
|
||||
// r0 = F(x0). By construction H(x0, 0) = 0, so x0 is exact at λ = 0.
|
||||
system
|
||||
.compute_residuals(&state, &mut residuals)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute initial residuals: {:?}", e),
|
||||
})?;
|
||||
let r0 = residuals.clone();
|
||||
let initial_norm = Self::residual_norm(&r0);
|
||||
|
||||
// F(x0) must be finite for the deformation H(x,λ)=F(x)-(1-λ)F(x0) to be
|
||||
// well defined. A non-finite r0 (e.g. a zero (P,h) cold start hitting the
|
||||
// fluid backend) would make every continuation step doomed; fail early
|
||||
// with an actionable message instead of running the whole loop.
|
||||
if !initial_norm.is_finite() {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: "Initial residual F(x0) is non-finite; the initial guess is infeasible \
|
||||
for the fluid backend (provide a physical initial_state)"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Already solved? Skip the continuation entirely.
|
||||
if initial_norm < self.tolerance {
|
||||
return Ok(ConvergedState::new(
|
||||
state,
|
||||
0,
|
||||
initial_norm,
|
||||
ConvergenceStatus::Converged,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
|
||||
let max_step = 4.0 / self.initial_steps.max(1) as f64;
|
||||
// Guard against a non-positive min step (e.g. struct-literal misconfig),
|
||||
// which would otherwise let dlambda shrink forever and hang the solver.
|
||||
let min_lambda_step = self.min_lambda_step.max(1e-12);
|
||||
let mut lambda = 0.0_f64;
|
||||
let mut dlambda = 1.0 / self.initial_steps.max(1) as f64;
|
||||
let mut total_iterations = 0usize;
|
||||
|
||||
while lambda < 1.0 {
|
||||
if let Some(timeout) = self.timeout {
|
||||
if start_time.elapsed() > timeout {
|
||||
let compute_ok = system.compute_residuals(&state, &mut residuals).is_ok();
|
||||
let final_residual = if compute_ok {
|
||||
Self::residual_norm(&residuals)
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
let diagnostics = self.failure_diagnostics(
|
||||
total_iterations,
|
||||
final_residual,
|
||||
if compute_ok { &residuals } else { &[] },
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
);
|
||||
return Err(SolverError::Timeout {
|
||||
timeout_ms: timeout.as_millis() as u64,
|
||||
}
|
||||
.with_optional_diagnostics(diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
let target = (lambda + dlambda).min(1.0);
|
||||
state_saved.copy_from_slice(&state);
|
||||
|
||||
match self.inner_newton(
|
||||
system,
|
||||
&mut state,
|
||||
&r0,
|
||||
target,
|
||||
&clipping_mask,
|
||||
&mut residuals,
|
||||
&mut residuals_h,
|
||||
&mut jacobian,
|
||||
&mut jacobian_builder,
|
||||
) {
|
||||
Ok(iters) => {
|
||||
total_iterations += iters;
|
||||
lambda = target;
|
||||
// Step succeeded: gently grow the increment for the next step.
|
||||
dlambda = (dlambda * 1.5).min(max_step);
|
||||
}
|
||||
Err(()) => {
|
||||
// Step failed: restore and halve the increment, then retry.
|
||||
state.copy_from_slice(&state_saved);
|
||||
dlambda *= 0.5;
|
||||
if dlambda < min_lambda_step {
|
||||
// Report the residual at the restored (last-good) state so
|
||||
// final_residual matches the state we actually return from.
|
||||
let compute_ok = system.compute_residuals(&state, &mut residuals).is_ok();
|
||||
let final_residual = if compute_ok {
|
||||
Self::residual_norm(&residuals)
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
let diagnostics = self.failure_diagnostics(
|
||||
total_iterations,
|
||||
final_residual,
|
||||
if compute_ok { &residuals } else { &[] },
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
);
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations: total_iterations,
|
||||
final_residual,
|
||||
}
|
||||
.with_optional_diagnostics(diagnostics));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// At λ = 1, H == F: verify the real system is actually solved.
|
||||
system
|
||||
.compute_residuals(&state, &mut residuals)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute final residuals: {:?}", e),
|
||||
})?;
|
||||
let final_norm = Self::residual_norm(&residuals);
|
||||
|
||||
if final_norm < self.tolerance {
|
||||
let status = if !system.saturated_variables().is_empty() {
|
||||
ConvergenceStatus::ControlSaturation
|
||||
} else {
|
||||
ConvergenceStatus::Converged
|
||||
};
|
||||
Ok(ConvergedState::new(
|
||||
state,
|
||||
total_iterations,
|
||||
final_norm,
|
||||
status,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
))
|
||||
} else {
|
||||
let diagnostics = self.failure_diagnostics(
|
||||
total_iterations,
|
||||
final_norm,
|
||||
&residuals,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
);
|
||||
Err(SolverError::NonConvergence {
|
||||
iterations: total_iterations,
|
||||
final_residual: final_norm,
|
||||
}
|
||||
.with_optional_diagnostics(diagnostics))
|
||||
}
|
||||
}
|
||||
|
||||
fn with_timeout(self, timeout: Duration) -> Self {
|
||||
Self {
|
||||
timeout: Some(timeout),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_homotopy_default_config() {
|
||||
let cfg = HomotopyConfig::default();
|
||||
assert_eq!(cfg.initial_steps, 10);
|
||||
assert!(!cfg.use_numerical_jacobian);
|
||||
assert!((cfg.tolerance - 1e-6).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_homotopy_builders() {
|
||||
let cfg = HomotopyConfig::default()
|
||||
.with_initial_steps(20)
|
||||
.with_numerical_jacobian(true)
|
||||
.with_initial_state(vec![1.0, 2.0]);
|
||||
assert_eq!(cfg.initial_steps, 20);
|
||||
assert!(cfg.use_numerical_jacobian);
|
||||
assert_eq!(cfg.initial_state, Some(vec![1.0, 2.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_homotopy_initial_steps_floor_is_one() {
|
||||
let cfg = HomotopyConfig::default().with_initial_steps(0);
|
||||
assert_eq!(cfg.initial_steps, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_homotopy_residual_norm() {
|
||||
assert!((HomotopyConfig::residual_norm(&[3.0, 4.0]) - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_homotopy_empty_system_errors() {
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
let mut solver = HomotopyConfig::default();
|
||||
assert!(solver.solve(&mut system).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_homotopy_with_timeout_sets_field() {
|
||||
let cfg = HomotopyConfig::default().with_timeout(Duration::from_millis(250));
|
||||
assert_eq!(cfg.timeout, Some(Duration::from_millis(250)));
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,12 @@
|
||||
//! ```
|
||||
|
||||
mod fallback;
|
||||
mod homotopy;
|
||||
mod newton_raphson;
|
||||
mod sequential_substitution;
|
||||
|
||||
pub use fallback::{FallbackConfig, FallbackSolver};
|
||||
pub use homotopy::HomotopyConfig;
|
||||
pub use newton_raphson::NewtonConfig;
|
||||
pub use sequential_substitution::PicardConfig;
|
||||
|
||||
@@ -83,11 +85,12 @@ impl Solver for SolverStrategy {
|
||||
|
||||
if let Ok(state) = &result {
|
||||
if state.is_converged() {
|
||||
// Post-solve validation checks
|
||||
// Convert Vec<f64> to SystemState for validation methods
|
||||
let system_state: entropyk_components::SystemState = state.state.clone().into();
|
||||
system.check_mass_balance(&system_state)?;
|
||||
system.check_energy_balance(&system_state)?;
|
||||
// Post-solve validation checks. Components index the state slice by
|
||||
// global index, so pass the raw (ṁ, P, h)-strided vector directly
|
||||
// rather than through the stride-2 SystemState conversion (CM1.2).
|
||||
let state_slice: &[f64] = &state.state;
|
||||
system.check_mass_balance(state_slice)?;
|
||||
system.check_energy_balance(state_slice)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ use crate::criteria::ConvergenceCriteria;
|
||||
use crate::jacobian::JacobianMatrix;
|
||||
use crate::metadata::SimulationMetadata;
|
||||
use crate::solver::{
|
||||
apply_newton_step, ConvergedState, ConvergenceDiagnostics, ConvergenceStatus,
|
||||
IterationDiagnostics, JacobianFreezingConfig, Solver, SolverError, SolverType,
|
||||
TimeoutConfig, VerboseConfig,
|
||||
apply_newton_step, dominant_residual, ConvergedState, ConvergenceDiagnostics,
|
||||
ConvergenceStatus, IterationDiagnostics, JacobianFreezingConfig, Solver, SolverError,
|
||||
SolverType, TimeoutConfig, VerboseConfig,
|
||||
};
|
||||
use crate::system::System;
|
||||
use entropyk_components::JacobianBuilder;
|
||||
@@ -154,7 +154,10 @@ impl NewtonConfig {
|
||||
) -> Option<SolverError> {
|
||||
if current_norm > self.divergence_threshold {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!("Residual {} exceeds threshold {}", current_norm, self.divergence_threshold),
|
||||
reason: format!(
|
||||
"Residual {} exceeds threshold {}",
|
||||
current_norm, self.divergence_threshold
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +165,10 @@ impl NewtonConfig {
|
||||
*divergence_count += 1;
|
||||
if *divergence_count >= 3 {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!("Residual increased 3x: {:.6e} → {:.6e}", previous_norm, current_norm),
|
||||
reason: format!(
|
||||
"Residual increased 3x: {:.6e} → {:.6e}",
|
||||
previous_norm, current_norm
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -201,7 +207,12 @@ impl NewtonConfig {
|
||||
|
||||
let new_norm = Self::residual_norm(new_residuals);
|
||||
if new_norm <= current_norm + self.line_search_armijo_c * alpha * gradient_dot_delta {
|
||||
tracing::debug!(alpha, old_norm = current_norm, new_norm, "Line search accepted");
|
||||
tracing::debug!(
|
||||
alpha,
|
||||
old_norm = current_norm,
|
||||
new_norm,
|
||||
"Line search accepted"
|
||||
);
|
||||
return Some(alpha);
|
||||
}
|
||||
|
||||
@@ -209,9 +220,45 @@ impl NewtonConfig {
|
||||
alpha *= 0.5;
|
||||
}
|
||||
|
||||
tracing::warn!("Line search failed after {} backtracks", self.line_search_max_backtracks);
|
||||
tracing::warn!(
|
||||
"Line search failed after {} backtracks",
|
||||
self.line_search_max_backtracks
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
fn finalize_failure_diagnostics(
|
||||
&self,
|
||||
mut diagnostics: Option<ConvergenceDiagnostics>,
|
||||
iterations: usize,
|
||||
final_residual: f64,
|
||||
best_residual: f64,
|
||||
elapsed_ms: u64,
|
||||
jacobian_condition_final: Option<f64>,
|
||||
final_state: Option<Vec<f64>>,
|
||||
) -> Option<ConvergenceDiagnostics> {
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = iterations;
|
||||
diag.final_residual = final_residual;
|
||||
diag.best_residual = best_residual;
|
||||
diag.converged = false;
|
||||
diag.timing_ms = elapsed_ms;
|
||||
diag.jacobian_condition_final = jacobian_condition_final;
|
||||
diag.final_solver = Some(SolverType::NewtonRaphson);
|
||||
|
||||
if self.verbose_config.dump_final_state {
|
||||
diag.final_state = final_state;
|
||||
let json_output = diag.dump_diagnostics(self.verbose_config.output_format);
|
||||
tracing::warn!(
|
||||
iterations,
|
||||
final_residual,
|
||||
"Non-convergence diagnostics:\n{}",
|
||||
json_output
|
||||
);
|
||||
}
|
||||
}
|
||||
diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for NewtonConfig {
|
||||
@@ -240,7 +287,9 @@ impl Solver for NewtonConfig {
|
||||
.map(|(_, c, _)| c.n_equations())
|
||||
.sum::<usize>()
|
||||
+ system.constraints().count()
|
||||
+ system.coupling_residual_count();
|
||||
+ system.coupling_residual_count()
|
||||
+ 2 * system.saturated_controller_count()
|
||||
+ system.mass_flow_closure_count();
|
||||
|
||||
if n_state == 0 || n_equations == 0 {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
@@ -248,15 +297,22 @@ impl Solver for NewtonConfig {
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-allocate all buffers
|
||||
let mut state: Vec<f64> = self
|
||||
.initial_state
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
debug_assert_eq!(s.len(), n_state, "initial_state length mismatch");
|
||||
if s.len() == n_state { s.clone() } else { vec![0.0; n_state] }
|
||||
})
|
||||
.unwrap_or_else(|| vec![0.0; n_state]);
|
||||
// Pre-allocate all buffers. A caller-supplied initial state MUST match
|
||||
// the full state length: a debug_assert would abort (violating zero-panic)
|
||||
// and a silent zeros fallback would solve a different problem. Fail cleanly.
|
||||
let mut state: Vec<f64> = match self.initial_state.as_ref() {
|
||||
Some(s) if s.len() == n_state => s.clone(),
|
||||
Some(s) => {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: format!(
|
||||
"initial_state length {} does not match system state length {}",
|
||||
s.len(),
|
||||
n_state
|
||||
),
|
||||
});
|
||||
}
|
||||
None => vec![0.0; n_state],
|
||||
};
|
||||
let mut residuals: Vec<f64> = vec![0.0; n_equations];
|
||||
let mut jacobian_builder = JacobianBuilder::new();
|
||||
let mut divergence_count: usize = 0;
|
||||
@@ -273,13 +329,13 @@ impl Solver for NewtonConfig {
|
||||
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
|
||||
let mut frozen_count: usize = 0;
|
||||
let mut force_recompute: bool = true;
|
||||
|
||||
|
||||
// Cached condition number (for verbose mode when Jacobian frozen)
|
||||
let mut cached_condition: Option<f64> = None;
|
||||
|
||||
// Pre-compute clipping mask
|
||||
let clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
|
||||
.map(|i| system.get_bounds_for_state_index(i))
|
||||
.map(|i| system.get_solver_bounds_for_state_index(i))
|
||||
.collect();
|
||||
|
||||
// Initial residual computation
|
||||
@@ -306,15 +362,32 @@ impl Solver for NewtonConfig {
|
||||
if let Some(ref criteria) = self.convergence_criteria {
|
||||
let report = criteria.check(&state, None, &residuals, system);
|
||||
if report.is_globally_converged() {
|
||||
tracing::info!(iterations = 0, final_residual = current_norm, "Converged at initial state (criteria)");
|
||||
tracing::info!(
|
||||
iterations = 0,
|
||||
final_residual = current_norm,
|
||||
"Converged at initial state (criteria)"
|
||||
);
|
||||
return Ok(ConvergedState::with_report(
|
||||
state, 0, current_norm, status, report, SimulationMetadata::new(system.input_hash()),
|
||||
state,
|
||||
0,
|
||||
current_norm,
|
||||
status,
|
||||
report,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tracing::info!(iterations = 0, final_residual = current_norm, "Converged at initial state");
|
||||
tracing::info!(
|
||||
iterations = 0,
|
||||
final_residual = current_norm,
|
||||
"Converged at initial state"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
state, 0, current_norm, status, SimulationMetadata::new(system.input_hash()),
|
||||
state,
|
||||
0,
|
||||
current_norm,
|
||||
status,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -327,7 +400,18 @@ impl Solver for NewtonConfig {
|
||||
if let Some(timeout) = self.timeout {
|
||||
if start_time.elapsed() > timeout {
|
||||
tracing::info!(iteration, elapsed_ms = ?start_time.elapsed(), best_residual, "Solver timed out");
|
||||
return self.handle_timeout(&best_state, best_residual, iteration - 1, timeout, system);
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration - 1,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return self
|
||||
.handle_timeout(&best_state, best_residual, iteration - 1, timeout, system)
|
||||
.map_err(|err| err.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +430,7 @@ impl Solver for NewtonConfig {
|
||||
};
|
||||
|
||||
let jacobian_frozen_this_iter = !should_recompute;
|
||||
|
||||
|
||||
if should_recompute {
|
||||
// Fresh Jacobian assembly (in-place update)
|
||||
jacobian_builder.clear();
|
||||
@@ -359,13 +443,15 @@ impl Solver for NewtonConfig {
|
||||
r.copy_from_slice(&r_vec);
|
||||
result.map(|_| ()).map_err(|e| format!("{:?}", e))
|
||||
};
|
||||
let jm = JacobianMatrix::numerical(compute_residuals_fn, &state, &residuals, 1e-5)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
let jm =
|
||||
JacobianMatrix::numerical(compute_residuals_fn, &state, &residuals, 1e-5)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute numerical Jacobian: {}", e),
|
||||
})?;
|
||||
jacobian_matrix.as_matrix_mut().copy_from(jm.as_matrix());
|
||||
} else {
|
||||
system.assemble_jacobian(&state, &mut jacobian_builder)
|
||||
system
|
||||
.assemble_jacobian(&state, &mut jacobian_builder)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to assemble Jacobian: {:?}", e),
|
||||
})?;
|
||||
@@ -374,19 +460,27 @@ impl Solver for NewtonConfig {
|
||||
|
||||
frozen_count = 0;
|
||||
force_recompute = false;
|
||||
|
||||
|
||||
// Compute and cache condition number if verbose mode enabled
|
||||
if verbose_enabled && self.verbose_config.log_jacobian_condition {
|
||||
let cond = jacobian_matrix.estimate_condition_number();
|
||||
cached_condition = cond;
|
||||
if let Some(c) = cond {
|
||||
tracing::info!(iteration, condition_number = c, "Jacobian condition number");
|
||||
tracing::info!(
|
||||
iteration,
|
||||
condition_number = c,
|
||||
"Jacobian condition number"
|
||||
);
|
||||
if c > 1e10 {
|
||||
tracing::warn!(iteration, condition_number = c, "Ill-conditioned Jacobian detected (κ > 1e10)");
|
||||
tracing::warn!(
|
||||
iteration,
|
||||
condition_number = c,
|
||||
"Ill-conditioned Jacobian detected (κ > 1e10)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tracing::debug!(iteration, "Fresh Jacobian computed");
|
||||
} else {
|
||||
frozen_count += 1;
|
||||
@@ -397,23 +491,49 @@ impl Solver for NewtonConfig {
|
||||
let delta = match jacobian_matrix.solve(&residuals) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return Err(SolverError::Divergence {
|
||||
reason: "Jacobian is singular".to_string(),
|
||||
});
|
||||
}
|
||||
.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
};
|
||||
|
||||
// Apply step with optional line search
|
||||
let alpha = if self.line_search {
|
||||
match self.line_search(
|
||||
system, &mut state, &delta, &residuals, current_norm,
|
||||
&mut state_copy, &mut new_residuals, &clipping_mask,
|
||||
system,
|
||||
&mut state,
|
||||
&delta,
|
||||
&residuals,
|
||||
current_norm,
|
||||
&mut state_copy,
|
||||
&mut new_residuals,
|
||||
&clipping_mask,
|
||||
) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return Err(SolverError::Divergence {
|
||||
reason: "Line search failed".to_string(),
|
||||
});
|
||||
}
|
||||
.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -421,16 +541,18 @@ impl Solver for NewtonConfig {
|
||||
1.0
|
||||
};
|
||||
|
||||
system.compute_residuals(&state, &mut 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);
|
||||
|
||||
|
||||
// Compute delta norm for diagnostics
|
||||
let delta_norm: f64 = state.iter()
|
||||
let delta_norm: f64 = state
|
||||
.iter()
|
||||
.zip(prev_iteration_state.iter())
|
||||
.map(|(s, p)| (s - p).powi(2))
|
||||
.sum::<f64>()
|
||||
@@ -444,9 +566,16 @@ impl Solver for NewtonConfig {
|
||||
|
||||
// Jacobian-freeze feedback
|
||||
if let Some(ref freeze_cfg) = self.jacobian_freezing {
|
||||
if previous_norm > 0.0 && current_norm / previous_norm >= (1.0 - freeze_cfg.threshold) {
|
||||
if previous_norm > 0.0
|
||||
&& current_norm / previous_norm >= (1.0 - freeze_cfg.threshold)
|
||||
{
|
||||
if frozen_count > 0 || !force_recompute {
|
||||
tracing::debug!(iteration, current_norm, previous_norm, "Unfreezing Jacobian");
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
current_norm,
|
||||
previous_norm,
|
||||
"Unfreezing Jacobian"
|
||||
);
|
||||
}
|
||||
force_recompute = true;
|
||||
frozen_count = 0;
|
||||
@@ -464,9 +593,10 @@ impl Solver for NewtonConfig {
|
||||
"Newton iteration"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Collect iteration diagnostics
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
let (max_residual_index, max_residual) = dominant_residual(&residuals);
|
||||
diag.push_iteration(IterationDiagnostics {
|
||||
iteration,
|
||||
residual_norm: current_norm,
|
||||
@@ -474,21 +604,29 @@ impl Solver for NewtonConfig {
|
||||
alpha: Some(alpha),
|
||||
jacobian_frozen: jacobian_frozen_this_iter,
|
||||
jacobian_condition: cached_condition,
|
||||
max_residual_index,
|
||||
max_residual,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::debug!(iteration, residual_norm = current_norm, alpha, "Newton iteration complete");
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
residual_norm = current_norm,
|
||||
alpha,
|
||||
"Newton iteration complete"
|
||||
);
|
||||
|
||||
// Check convergence
|
||||
let converged = if let Some(ref criteria) = self.convergence_criteria {
|
||||
let report = criteria.check(&state, Some(&prev_iteration_state), &residuals, system);
|
||||
let report =
|
||||
criteria.check(&state, Some(&prev_iteration_state), &residuals, system);
|
||||
if report.is_globally_converged() {
|
||||
let status = if !system.saturated_variables().is_empty() {
|
||||
ConvergenceStatus::ControlSaturation
|
||||
} else {
|
||||
ConvergenceStatus::Converged
|
||||
};
|
||||
|
||||
|
||||
// Finalize diagnostics
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = iteration;
|
||||
@@ -498,19 +636,33 @@ impl Solver for NewtonConfig {
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.jacobian_condition_final = cached_condition;
|
||||
diag.final_solver = Some(SolverType::NewtonRaphson);
|
||||
|
||||
|
||||
if self.verbose_config.log_residuals {
|
||||
tracing::info!("{}", diag.summary());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(iterations = iteration, final_residual = current_norm, "Converged (criteria)");
|
||||
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
"Converged (criteria)"
|
||||
);
|
||||
let result = ConvergedState::with_report(
|
||||
state, iteration, current_norm, status, report, SimulationMetadata::new(system.input_hash()),
|
||||
state,
|
||||
iteration,
|
||||
current_norm,
|
||||
status,
|
||||
report,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
);
|
||||
return Ok(if let Some(d) = diagnostics {
|
||||
ConvergedState { diagnostics: Some(d), ..result }
|
||||
} else { result });
|
||||
ConvergedState {
|
||||
diagnostics: Some(d),
|
||||
..result
|
||||
}
|
||||
} else {
|
||||
result
|
||||
});
|
||||
}
|
||||
false
|
||||
} else {
|
||||
@@ -523,7 +675,7 @@ impl Solver for NewtonConfig {
|
||||
} else {
|
||||
ConvergenceStatus::Converged
|
||||
};
|
||||
|
||||
|
||||
// Finalize diagnostics
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = iteration;
|
||||
@@ -533,54 +685,76 @@ impl Solver for NewtonConfig {
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.jacobian_condition_final = cached_condition;
|
||||
diag.final_solver = Some(SolverType::NewtonRaphson);
|
||||
|
||||
|
||||
if self.verbose_config.log_residuals {
|
||||
tracing::info!("{}", diag.summary());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(iterations = iteration, final_residual = current_norm, "Converged");
|
||||
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
"Converged"
|
||||
);
|
||||
let result = ConvergedState::new(
|
||||
state, iteration, current_norm, status, SimulationMetadata::new(system.input_hash()),
|
||||
state,
|
||||
iteration,
|
||||
current_norm,
|
||||
status,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
);
|
||||
return Ok(if let Some(d) = diagnostics {
|
||||
ConvergedState { diagnostics: Some(d), ..result }
|
||||
} else { result });
|
||||
ConvergedState {
|
||||
diagnostics: Some(d),
|
||||
..result
|
||||
}
|
||||
} else {
|
||||
result
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(err) = self.check_divergence(current_norm, previous_norm, &mut divergence_count) {
|
||||
tracing::warn!(iteration, residual_norm = current_norm, "Divergence detected");
|
||||
return Err(err);
|
||||
if let Some(err) =
|
||||
self.check_divergence(current_norm, previous_norm, &mut divergence_count)
|
||||
{
|
||||
tracing::warn!(
|
||||
iteration,
|
||||
residual_norm = current_norm,
|
||||
"Divergence detected"
|
||||
);
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return Err(err.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
// Non-convergence: dump diagnostics if enabled
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = self.max_iterations;
|
||||
diag.final_residual = current_norm;
|
||||
diag.best_residual = best_residual;
|
||||
diag.converged = false;
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.jacobian_condition_final = cached_condition;
|
||||
diag.final_solver = Some(SolverType::NewtonRaphson);
|
||||
|
||||
if self.verbose_config.dump_final_state {
|
||||
diag.final_state = Some(state.clone());
|
||||
let json_output = diag.dump_diagnostics(self.verbose_config.output_format);
|
||||
tracing::warn!(
|
||||
iterations = self.max_iterations,
|
||||
final_residual = current_norm,
|
||||
"Non-convergence diagnostics:\n{}",
|
||||
json_output
|
||||
);
|
||||
}
|
||||
}
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
self.max_iterations,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
|
||||
tracing::warn!(max_iterations = self.max_iterations, final_residual = current_norm, "Did not converge");
|
||||
tracing::warn!(
|
||||
max_iterations = self.max_iterations,
|
||||
final_residual = current_norm,
|
||||
"Did not converge"
|
||||
);
|
||||
Err(SolverError::NonConvergence {
|
||||
iterations: self.max_iterations,
|
||||
final_residual: current_norm,
|
||||
})
|
||||
}
|
||||
.with_optional_diagnostics(failure_diagnostics))
|
||||
}
|
||||
|
||||
fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
//! Provides [`PicardConfig`] which implements Picard iteration for solving
|
||||
//! systems of non-linear equations. Slower than Newton-Raphson but more robust.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nalgebra::{DMatrix, DVector};
|
||||
|
||||
use crate::criteria::ConvergenceCriteria;
|
||||
use crate::metadata::SimulationMetadata;
|
||||
use crate::solver::{
|
||||
ConvergedState, ConvergenceDiagnostics, ConvergenceStatus, IterationDiagnostics, Solver,
|
||||
SolverError, SolverType, TimeoutConfig, VerboseConfig,
|
||||
dominant_residual, ConvergedState, ConvergenceDiagnostics, ConvergenceStatus,
|
||||
IterationDiagnostics, Solver, SolverError, SolverType, TimeoutConfig, VerboseConfig,
|
||||
};
|
||||
use crate::system::System;
|
||||
|
||||
@@ -43,6 +46,13 @@ pub struct PicardConfig {
|
||||
pub convergence_criteria: Option<ConvergenceCriteria>,
|
||||
/// Verbose mode configuration for diagnostics.
|
||||
pub verbose_config: VerboseConfig,
|
||||
/// Anderson acceleration depth `m` (history window). `0` disables acceleration
|
||||
/// and the solver behaves as plain relaxed Picard (default). Typical useful
|
||||
/// values are 3–5. See [`PicardConfig::with_anderson`].
|
||||
pub anderson_depth: usize,
|
||||
/// Tikhonov regularization added to the Anderson least-squares normal matrix
|
||||
/// for numerical stability. Default: 1e-10. Only used when `anderson_depth > 0`.
|
||||
pub anderson_regularization: f64,
|
||||
}
|
||||
|
||||
impl Default for PicardConfig {
|
||||
@@ -60,6 +70,8 @@ impl Default for PicardConfig {
|
||||
initial_state: None,
|
||||
convergence_criteria: None,
|
||||
verbose_config: VerboseConfig::default(),
|
||||
anderson_depth: 0,
|
||||
anderson_regularization: 1e-10,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +102,23 @@ impl PicardConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables Anderson acceleration with history depth `m` (Story: solver speed).
|
||||
///
|
||||
/// Anderson acceleration (Walker & Ni, 2011) turns the linearly-convergent
|
||||
/// relaxed Picard fixed-point iteration into a super-linearly convergent one by
|
||||
/// extrapolating from the last `m` residual/map-value pairs via a small
|
||||
/// least-squares problem. `m = 0` disables it (plain relaxed Picard). Values of
|
||||
/// 3–5 typically cut the iteration count by 2–3× on stiff refrigeration cycles
|
||||
/// while adding only an `O(m² · n)` least-squares solve per iteration.
|
||||
///
|
||||
/// # Reference
|
||||
/// Walker, H.F., Ni, P. (2011). "Anderson acceleration for fixed-point
|
||||
/// iterations." *SIAM J. Numerical Analysis*, 49(4):1715–1735.
|
||||
pub fn with_anderson(mut self, depth: usize) -> Self {
|
||||
self.anderson_depth = depth;
|
||||
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()
|
||||
@@ -200,6 +229,37 @@ impl PicardConfig {
|
||||
*x -= omega * r;
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_failure_diagnostics(
|
||||
&self,
|
||||
mut diagnostics: Option<ConvergenceDiagnostics>,
|
||||
iterations: usize,
|
||||
final_residual: f64,
|
||||
best_residual: f64,
|
||||
elapsed_ms: u64,
|
||||
final_state: Option<Vec<f64>>,
|
||||
) -> Option<ConvergenceDiagnostics> {
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = iterations;
|
||||
diag.final_residual = final_residual;
|
||||
diag.best_residual = best_residual;
|
||||
diag.converged = false;
|
||||
diag.timing_ms = elapsed_ms;
|
||||
diag.final_solver = Some(SolverType::SequentialSubstitution);
|
||||
|
||||
if self.verbose_config.dump_final_state {
|
||||
diag.final_state = final_state;
|
||||
let json_output = diag.dump_diagnostics(self.verbose_config.output_format);
|
||||
tracing::warn!(
|
||||
iterations,
|
||||
final_residual,
|
||||
"Non-convergence diagnostics:\n{}",
|
||||
json_output
|
||||
);
|
||||
}
|
||||
}
|
||||
diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for PicardConfig {
|
||||
@@ -231,7 +291,9 @@ impl Solver for PicardConfig {
|
||||
.map(|(_, c, _)| c.n_equations())
|
||||
.sum::<usize>()
|
||||
+ system.constraints().count()
|
||||
+ system.coupling_residual_count();
|
||||
+ system.coupling_residual_count()
|
||||
+ 2 * system.saturated_controller_count()
|
||||
+ system.mass_flow_closure_count();
|
||||
|
||||
// Validate system
|
||||
if n_state == 0 || n_equations == 0 {
|
||||
@@ -251,25 +313,22 @@ impl Solver for PicardConfig {
|
||||
}
|
||||
|
||||
// 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]);
|
||||
// Story 4.6 - AC: #8: Use initial_state if provided, else start from zeros.
|
||||
// A mismatched length is a hard error (zero-panic; no silent zeros fallback
|
||||
// that would solve a different problem) — consistent with Newton/Homotopy.
|
||||
let mut state: Vec<f64> = match self.initial_state.as_ref() {
|
||||
Some(s) if s.len() == n_state => s.clone(),
|
||||
Some(s) => {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: format!(
|
||||
"initial_state length {} does not match system state length {}",
|
||||
s.len(),
|
||||
n_state
|
||||
),
|
||||
});
|
||||
}
|
||||
None => 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;
|
||||
@@ -310,6 +369,16 @@ impl Solver for PicardConfig {
|
||||
));
|
||||
}
|
||||
|
||||
// Optional Anderson accelerator (disabled when depth == 0).
|
||||
let mut anderson = if self.anderson_depth > 0 {
|
||||
Some(AndersonAccelerator::new(
|
||||
self.anderson_depth,
|
||||
self.anderson_regularization,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Main Picard iteration loop
|
||||
for iteration in 1..=self.max_iterations {
|
||||
// Save state before step for convergence criteria delta checks
|
||||
@@ -327,18 +396,28 @@ impl Solver for PicardConfig {
|
||||
);
|
||||
|
||||
// Story 4.5 - AC: #2, #6: Return best state or error based on config
|
||||
return self.handle_timeout(
|
||||
&best_state,
|
||||
best_residual,
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration - 1,
|
||||
timeout,
|
||||
system,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return self
|
||||
.handle_timeout(&best_state, best_residual, iteration - 1, timeout, system)
|
||||
.map_err(|err| err.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply relaxed update: x_new = x_old - omega * residual (AC: #2, #3)
|
||||
Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);
|
||||
// Apply update. With Anderson acceleration enabled, extrapolate from the
|
||||
// residual/map-value history; otherwise use plain relaxed Picard.
|
||||
// Both share the same underlying fixed-point map G(x) = x - ω·F(x).
|
||||
if let Some(acc) = anderson.as_mut() {
|
||||
acc.next_state_into(&mut state, &residuals, self.relaxation_factor);
|
||||
} else {
|
||||
Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);
|
||||
}
|
||||
|
||||
// Compute new residuals
|
||||
system
|
||||
@@ -349,9 +428,10 @@ impl Solver for PicardConfig {
|
||||
|
||||
previous_norm = current_norm;
|
||||
current_norm = Self::residual_norm(&residuals);
|
||||
|
||||
|
||||
// Compute delta norm for diagnostics
|
||||
let delta_norm: f64 = state.iter()
|
||||
let delta_norm: f64 = state
|
||||
.iter()
|
||||
.zip(prev_iteration_state.iter())
|
||||
.map(|(s, p)| (s - p).powi(2))
|
||||
.sum::<f64>()
|
||||
@@ -378,16 +458,19 @@ impl Solver for PicardConfig {
|
||||
"Picard iteration"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Collect iteration diagnostics
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
let (max_residual_index, max_residual) = dominant_residual(&residuals);
|
||||
diag.push_iteration(IterationDiagnostics {
|
||||
iteration,
|
||||
residual_norm: current_norm,
|
||||
delta_norm,
|
||||
alpha: None, // Picard doesn't use line search
|
||||
jacobian_frozen: false, // Picard doesn't use Jacobian
|
||||
alpha: None, // Picard doesn't use line search
|
||||
jacobian_frozen: false, // Picard doesn't use Jacobian
|
||||
jacobian_condition: None, // No Jacobian in Picard
|
||||
max_residual_index,
|
||||
max_residual,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,12 +494,12 @@ impl Solver for PicardConfig {
|
||||
diag.converged = true;
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.final_solver = Some(SolverType::SequentialSubstitution);
|
||||
|
||||
|
||||
if self.verbose_config.log_residuals {
|
||||
tracing::info!("{}", diag.summary());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
@@ -432,8 +515,13 @@ impl Solver for PicardConfig {
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
);
|
||||
return Ok(if let Some(d) = diagnostics {
|
||||
ConvergedState { diagnostics: Some(d), ..result }
|
||||
} else { result });
|
||||
ConvergedState {
|
||||
diagnostics: Some(d),
|
||||
..result
|
||||
}
|
||||
} else {
|
||||
result
|
||||
});
|
||||
}
|
||||
false
|
||||
} else {
|
||||
@@ -449,12 +537,12 @@ impl Solver for PicardConfig {
|
||||
diag.converged = true;
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.final_solver = Some(SolverType::SequentialSubstitution);
|
||||
|
||||
|
||||
if self.verbose_config.log_residuals {
|
||||
tracing::info!("{}", diag.summary());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
@@ -469,8 +557,13 @@ impl Solver for PicardConfig {
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
);
|
||||
return Ok(if let Some(d) = diagnostics {
|
||||
ConvergedState { diagnostics: Some(d), ..result }
|
||||
} else { result });
|
||||
ConvergedState {
|
||||
diagnostics: Some(d),
|
||||
..result
|
||||
}
|
||||
} else {
|
||||
result
|
||||
});
|
||||
}
|
||||
|
||||
// Check divergence (AC: #5)
|
||||
@@ -482,30 +575,27 @@ impl Solver for PicardConfig {
|
||||
residual_norm = current_norm,
|
||||
"Divergence detected"
|
||||
);
|
||||
return Err(err);
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return Err(err.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
// Non-convergence: dump diagnostics if enabled
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = self.max_iterations;
|
||||
diag.final_residual = current_norm;
|
||||
diag.best_residual = best_residual;
|
||||
diag.converged = false;
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.final_solver = Some(SolverType::SequentialSubstitution);
|
||||
|
||||
if self.verbose_config.dump_final_state {
|
||||
diag.final_state = Some(state.clone());
|
||||
let json_output = diag.dump_diagnostics(self.verbose_config.output_format);
|
||||
tracing::warn!(
|
||||
iterations = self.max_iterations,
|
||||
final_residual = current_norm,
|
||||
"Non-convergence diagnostics:\n{}",
|
||||
json_output
|
||||
);
|
||||
}
|
||||
}
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
self.max_iterations,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
Some(state.clone()),
|
||||
);
|
||||
|
||||
// Max iterations exceeded
|
||||
tracing::warn!(
|
||||
@@ -516,7 +606,8 @@ impl Solver for PicardConfig {
|
||||
Err(SolverError::NonConvergence {
|
||||
iterations: self.max_iterations,
|
||||
final_residual: current_norm,
|
||||
})
|
||||
}
|
||||
.with_optional_diagnostics(failure_diagnostics))
|
||||
}
|
||||
|
||||
fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
@@ -525,6 +616,110 @@ impl Solver for PicardConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Anderson acceleration state for the relaxed Picard fixed-point iteration.
|
||||
///
|
||||
/// The underlying fixed-point map is `G(x) = x - ω·F(x)` where `F` is the residual
|
||||
/// vector and `ω` the relaxation factor. Define the map residual `f(x) = G(x) - x =
|
||||
/// -ω·F(x)`. Anderson acceleration maintains the last `m` differences of `f` and `G`
|
||||
/// and, each iteration, solves the small least-squares problem
|
||||
/// `min_γ ‖f_k - ΔF·γ‖` then sets `x_{k+1} = G_k - ΔG·γ` (Walker & Ni, 2011,
|
||||
/// following H. Walker's reference `anderson.m`). With `m = 0` (empty history) it
|
||||
/// reduces exactly to the plain step `x_{k+1} = G_k`.
|
||||
struct AndersonAccelerator {
|
||||
depth: usize,
|
||||
regularization: f64,
|
||||
/// Previous map-residual f = G(x) - x.
|
||||
f_prev: Option<Vec<f64>>,
|
||||
/// Previous map value G(x).
|
||||
g_prev: Option<Vec<f64>>,
|
||||
/// History of Δf columns (most-recent at back), capped at `depth`.
|
||||
df: VecDeque<Vec<f64>>,
|
||||
/// History of ΔG columns (most-recent at back), capped at `depth`.
|
||||
dg: VecDeque<Vec<f64>>,
|
||||
}
|
||||
|
||||
impl AndersonAccelerator {
|
||||
fn new(depth: usize, regularization: f64) -> Self {
|
||||
Self {
|
||||
depth,
|
||||
regularization,
|
||||
f_prev: None,
|
||||
g_prev: None,
|
||||
df: VecDeque::with_capacity(depth),
|
||||
dg: VecDeque::with_capacity(depth),
|
||||
}
|
||||
}
|
||||
|
||||
/// Advances `state` in place from `x_k` to the accelerated `x_{k+1}`, given the
|
||||
/// current residual vector `F(x_k)` and relaxation factor `ω`.
|
||||
fn next_state_into(&mut self, state: &mut [f64], residual: &[f64], omega: f64) {
|
||||
let n = state.len();
|
||||
// Map residual f = -ω·F and fixed-point map value G = x + f.
|
||||
let fval: Vec<f64> = residual.iter().map(|r| -omega * r).collect();
|
||||
let gval: Vec<f64> = state.iter().zip(&fval).map(|(x, f)| x + f).collect();
|
||||
|
||||
// Push newest history differences.
|
||||
if let (Some(fp), Some(gp)) = (self.f_prev.as_ref(), self.g_prev.as_ref()) {
|
||||
let df_col: Vec<f64> = fval.iter().zip(fp).map(|(a, b)| a - b).collect();
|
||||
let dg_col: Vec<f64> = gval.iter().zip(gp).map(|(a, b)| a - b).collect();
|
||||
self.df.push_back(df_col);
|
||||
self.dg.push_back(dg_col);
|
||||
while self.df.len() > self.depth {
|
||||
self.df.pop_front();
|
||||
self.dg.pop_front();
|
||||
}
|
||||
}
|
||||
self.f_prev = Some(fval.clone());
|
||||
self.g_prev = Some(gval.clone());
|
||||
|
||||
let m = self.df.len();
|
||||
if m == 0 {
|
||||
// No history yet — plain relaxed step.
|
||||
state.copy_from_slice(&gval);
|
||||
return;
|
||||
}
|
||||
|
||||
// Solve the small least-squares problem for γ via regularized normal
|
||||
// equations: (ΔFᵀΔF + λI)·γ = ΔFᵀ·f_k. `m` is at most `depth` (small).
|
||||
let mut ata = DMatrix::<f64>::zeros(m, m);
|
||||
let mut atb = DVector::<f64>::zeros(m);
|
||||
for i in 0..m {
|
||||
for j in i..m {
|
||||
let mut s = 0.0;
|
||||
for k in 0..n {
|
||||
s += self.df[i][k] * self.df[j][k];
|
||||
}
|
||||
ata[(i, j)] = s;
|
||||
ata[(j, i)] = s;
|
||||
}
|
||||
ata[(i, i)] += self.regularization;
|
||||
let mut s = 0.0;
|
||||
for k in 0..n {
|
||||
s += self.df[i][k] * fval[k];
|
||||
}
|
||||
atb[i] = s;
|
||||
}
|
||||
|
||||
let gamma = match ata.clone().lu().solve(&atb) {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
// Singular even with regularization — fall back to plain step.
|
||||
state.copy_from_slice(&gval);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// x_{k+1} = G_k - ΔG·γ.
|
||||
for k in 0..n {
|
||||
let mut acc = gval[k];
|
||||
for (i, g) in gamma.iter().enumerate() {
|
||||
acc -= g * self.dg[i][k];
|
||||
}
|
||||
state[k] = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -570,4 +765,99 @@ mod tests {
|
||||
system.finalize().unwrap();
|
||||
assert!(boxed.solve(&mut system).is_err());
|
||||
}
|
||||
|
||||
// ── Anderson acceleration ────────────────────────────────────────────────
|
||||
|
||||
/// Reference linear residual F(x) = A·x - b. Its unique root is x* = A⁻¹·b.
|
||||
/// The relaxed Picard map is x_{k+1} = x_k - ω·(A·x_k - b).
|
||||
fn linear_residual(a: &[[f64; 2]; 2], b: &[f64; 2], x: &[f64]) -> Vec<f64> {
|
||||
vec![
|
||||
a[0][0] * x[0] + a[0][1] * x[1] - b[0],
|
||||
a[1][0] * x[0] + a[1][1] * x[1] - b[1],
|
||||
]
|
||||
}
|
||||
|
||||
fn residual_norm2(r: &[f64]) -> f64 {
|
||||
r.iter().map(|v| v * v).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anderson_depth_zero_matches_plain_relaxation() {
|
||||
// With no history, next_state_into must equal x - ω·F(x).
|
||||
let mut acc = AndersonAccelerator::new(0, 1e-10);
|
||||
let mut state = vec![10.0, 20.0];
|
||||
let residuals = vec![1.0, 2.0];
|
||||
acc.next_state_into(&mut state, &residuals, 0.5);
|
||||
assert!((state[0] - 9.5).abs() < 1e-15);
|
||||
assert!((state[1] - 19.0).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anderson_converges_faster_than_plain_picard() {
|
||||
// Stiff-ish SPD system where plain relaxed Picard converges slowly.
|
||||
let a = [[8.0, 1.0], [1.0, 3.0]];
|
||||
let b = [9.0, 4.0]; // exact root x* = [1, 1]
|
||||
let omega = 0.12; // deliberately small → slow plain Picard
|
||||
let tol = 1e-9;
|
||||
let max_iter = 2000;
|
||||
|
||||
let count_iters = |depth: usize| -> (usize, Vec<f64>) {
|
||||
let mut state = vec![0.0, 0.0];
|
||||
let mut acc = AndersonAccelerator::new(depth, 1e-12);
|
||||
for it in 1..=max_iter {
|
||||
let r = linear_residual(&a, &b, &state);
|
||||
if residual_norm2(&r) < tol {
|
||||
return (it - 1, state);
|
||||
}
|
||||
acc.next_state_into(&mut state, &r, omega);
|
||||
}
|
||||
(max_iter, state)
|
||||
};
|
||||
|
||||
let (plain_iters, _) = count_iters(0);
|
||||
let (anderson_iters, sol) = count_iters(3);
|
||||
|
||||
// Anderson must converge, land on the true root, and use far fewer steps.
|
||||
assert!(anderson_iters < max_iter, "Anderson did not converge");
|
||||
assert!((sol[0] - 1.0).abs() < 1e-6 && (sol[1] - 1.0).abs() < 1e-6);
|
||||
assert!(
|
||||
anderson_iters * 3 < plain_iters,
|
||||
"Anderson ({}) should be much faster than plain Picard ({})",
|
||||
anderson_iters,
|
||||
plain_iters
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anderson_solves_where_plain_diverges_marginally() {
|
||||
// Anderson should still hit the exact root of a well-posed linear system.
|
||||
let a = [[4.0, 1.0], [2.0, 5.0]];
|
||||
let b = [6.0, 9.0];
|
||||
// exact root: solve → x=[1, 1.4? ] compute: 4x+y=6, 2x+5y=9
|
||||
// From first: y = 6-4x; sub: 2x+5(6-4x)=9 → 2x+30-20x=9 → -18x=-21 → x=7/6
|
||||
// y = 6-4*7/6 = 6-28/6 = 8/6 = 4/3
|
||||
let omega = 0.15;
|
||||
let mut state = vec![0.0, 0.0];
|
||||
let mut acc = AndersonAccelerator::new(4, 1e-12);
|
||||
let mut converged = false;
|
||||
for _ in 0..5000 {
|
||||
let r = linear_residual(&a, &b, &state);
|
||||
if residual_norm2(&r) < 1e-9 {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
acc.next_state_into(&mut state, &r, omega);
|
||||
}
|
||||
assert!(converged);
|
||||
assert!((state[0] - 7.0 / 6.0).abs() < 1e-6);
|
||||
assert!((state[1] - 4.0 / 3.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_anderson_builder_sets_depth() {
|
||||
let cfg = PicardConfig::default().with_anderson(5);
|
||||
assert_eq!(cfg.anderson_depth, 5);
|
||||
// Default remains disabled.
|
||||
assert_eq!(PicardConfig::default().anderson_depth, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user