Files
Entropyk/crates/solver/src/strategies/newton_raphson.rs
sepehr 3358b74342 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>
2026-07-17 22:46:46 +02:00

794 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Newton-Raphson solver implementation.
//!
//! Provides [`NewtonConfig`] which implements the Newton-Raphson method for
//! solving systems of non-linear equations with quadratic convergence.
use std::time::{Duration, Instant};
use crate::criteria::ConvergenceCriteria;
use crate::jacobian::JacobianMatrix;
use crate::metadata::SimulationMetadata;
use crate::solver::{
apply_newton_step, dominant_residual, ConvergedState, ConvergenceDiagnostics,
ConvergenceStatus, IterationDiagnostics, JacobianFreezingConfig, Solver, SolverError,
SolverType, TimeoutConfig, VerboseConfig,
};
use crate::system::System;
use entropyk_components::JacobianBuilder;
/// Configuration for the Newton-Raphson solver.
///
/// Solves F(x) = 0 by iterating: x_{k+1} = x_k - α·J^{-1}·r(x_k)
/// where J is the Jacobian matrix and α is the step length.
#[derive(Debug, Clone, PartialEq)]
pub struct NewtonConfig {
/// Maximum iterations before declaring non-convergence. Default: 100.
pub max_iterations: usize,
/// Convergence tolerance (L2 norm). Default: 1e-6.
pub tolerance: f64,
/// Enable Armijo line-search. Default: false.
pub line_search: bool,
/// Optional time budget.
pub timeout: Option<Duration>,
/// Use numerical Jacobian (finite differences). Default: false.
pub use_numerical_jacobian: bool,
/// Armijo condition constant. Default: 1e-4.
pub line_search_armijo_c: f64,
/// Max backtracking iterations. Default: 20.
pub line_search_max_backtracks: usize,
/// Divergence threshold. Default: 1e10.
pub divergence_threshold: f64,
/// Timeout behavior configuration.
pub timeout_config: TimeoutConfig,
/// Previous state for ZOH fallback.
pub previous_state: Option<Vec<f64>>,
/// Residual for previous_state.
pub previous_residual: Option<f64>,
/// Smart initial state for cold-start.
pub initial_state: Option<Vec<f64>>,
/// Multi-circuit convergence criteria.
pub convergence_criteria: Option<ConvergenceCriteria>,
/// Jacobian-freezing optimization.
pub jacobian_freezing: Option<JacobianFreezingConfig>,
/// Verbose mode configuration for diagnostics.
pub verbose_config: VerboseConfig,
}
impl Default for NewtonConfig {
fn default() -> Self {
Self {
max_iterations: 100,
tolerance: 1e-6,
line_search: false,
timeout: None,
use_numerical_jacobian: false,
line_search_armijo_c: 1e-4,
line_search_max_backtracks: 20,
divergence_threshold: 1e10,
timeout_config: TimeoutConfig::default(),
previous_state: None,
previous_residual: None,
initial_state: None,
convergence_criteria: None,
jacobian_freezing: None,
verbose_config: VerboseConfig::default(),
}
}
}
impl NewtonConfig {
/// Sets the initial state for cold-start solving.
pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
self.initial_state = Some(state);
self
}
/// Sets multi-circuit convergence criteria.
pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
self.convergence_criteria = Some(criteria);
self
}
/// Enables Jacobian-freezing optimization.
pub fn with_jacobian_freezing(mut self, config: JacobianFreezingConfig) -> Self {
self.jacobian_freezing = Some(config);
self
}
/// Enables verbose mode for diagnostics.
pub fn with_verbose(mut self, config: VerboseConfig) -> Self {
self.verbose_config = config;
self
}
/// Computes the L2 norm of the residual vector.
fn residual_norm(residuals: &[f64]) -> f64 {
residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
}
/// Handles timeout based on configuration.
fn handle_timeout(
&self,
best_state: &[f64],
best_residual: f64,
iterations: usize,
timeout: Duration,
system: &System,
) -> Result<ConvergedState, SolverError> {
if !self.timeout_config.return_best_state_on_timeout {
return Err(SolverError::Timeout {
timeout_ms: timeout.as_millis() as u64,
});
}
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, residual, "ZOH fallback");
return Ok(ConvergedState::new(
prev_state.clone(),
iterations,
residual,
ConvergenceStatus::TimedOutWithBestState,
SimulationMetadata::new(system.input_hash()),
));
}
}
tracing::info!(iterations, 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.
fn check_divergence(
&self,
current_norm: f64,
previous_norm: f64,
divergence_count: &mut usize,
) -> Option<SolverError> {
if current_norm > self.divergence_threshold {
return Some(SolverError::Divergence {
reason: format!(
"Residual {} exceeds threshold {}",
current_norm, self.divergence_threshold
),
});
}
if current_norm > previous_norm {
*divergence_count += 1;
if *divergence_count >= 3 {
return Some(SolverError::Divergence {
reason: format!(
"Residual increased 3x: {:.6e} → {:.6e}",
previous_norm, current_norm
),
});
}
} else {
*divergence_count = 0;
}
None
}
/// Performs Armijo line search. Returns Some(alpha) if valid step found.
/// hot path. `state_copy` and `new_residuals` must have appropriate lengths.
#[allow(clippy::too_many_arguments)]
fn line_search(
&self,
system: &System,
state: &mut Vec<f64>,
delta: &[f64],
_residuals: &[f64],
current_norm: f64,
state_copy: &mut [f64],
new_residuals: &mut Vec<f64>,
clipping_mask: &[Option<(f64, f64)>],
) -> Option<f64> {
let mut alpha: f64 = 1.0;
state_copy.copy_from_slice(state);
let gradient_dot_delta = -current_norm;
for _backtrack in 0..self.line_search_max_backtracks {
apply_newton_step(state, delta, clipping_mask, alpha);
if system.compute_residuals(state, new_residuals).is_err() {
state.copy_from_slice(state_copy);
alpha *= 0.5;
continue;
}
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"
);
return Some(alpha);
}
state.copy_from_slice(state_copy);
alpha *= 0.5;
}
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 {
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
let start_time = Instant::now();
// Initialize diagnostics collection if verbose mode enabled
let verbose_enabled = self.verbose_config.enabled && self.verbose_config.is_any_enabled();
let mut diagnostics = if verbose_enabled {
Some(ConvergenceDiagnostics::with_capacity(self.max_iterations))
} else {
None
};
tracing::info!(
max_iterations = self.max_iterations,
tolerance = self.tolerance,
line_search = self.line_search,
verbose = verbose_enabled,
"Newton-Raphson solver starting"
);
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(),
});
}
// 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;
let mut previous_norm: f64;
let mut state_copy: Vec<f64> = vec![0.0; n_state]; // Pre-allocated for line search
let mut new_residuals: Vec<f64> = vec![0.0; n_equations]; // Pre-allocated for line search
let mut prev_iteration_state: Vec<f64> = vec![0.0; n_state]; // For convergence delta check
// Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
let mut best_state: Vec<f64> = vec![0.0; n_state];
let mut best_residual: f64;
// Jacobian-freezing tracking state
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_solver_bounds_for_state_index(i))
.collect();
// 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);
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 {
let status = if !system.saturated_variables().is_empty() {
ConvergenceStatus::ControlSaturation
} else {
ConvergenceStatus::Converged
};
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)"
);
return Ok(ConvergedState::with_report(
state,
0,
current_norm,
status,
report,
SimulationMetadata::new(system.input_hash()),
));
}
} else {
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()),
));
}
}
// Main Newton-Raphson iteration loop
for iteration in 1..=self.max_iterations {
prev_iteration_state.copy_from_slice(&state);
// Check timeout
if let Some(timeout) = self.timeout {
if start_time.elapsed() > timeout {
tracing::info!(iteration, elapsed_ms = ?start_time.elapsed(), best_residual, "Solver timed out");
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));
}
}
// Jacobian Assembly / Freeze Decision
let should_recompute = if let Some(ref freeze_cfg) = self.jacobian_freezing {
if force_recompute {
true
} else if frozen_count >= freeze_cfg.max_frozen_iters {
tracing::debug!(iteration, frozen_count, "Jacobian freeze limit reached");
true
} else {
false
}
} else {
true
};
let jacobian_frozen_this_iter = !should_recompute;
if should_recompute {
// Fresh Jacobian assembly (in-place update)
jacobian_builder.clear();
if self.use_numerical_jacobian {
// Numerical Jacobian via finite differences
let compute_residuals_fn = |s: &[f64], r: &mut [f64]| {
let s_vec = s.to_vec();
let mut r_vec = vec![0.0; r.len()];
let result = system.compute_residuals(&s_vec, &mut r_vec);
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 {
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)
.map_err(|e| SolverError::InvalidSystem {
message: format!("Failed to assemble Jacobian: {:?}", e),
})?;
jacobian_matrix.update_from_builder(jacobian_builder.entries());
};
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"
);
if c > 1e10 {
tracing::warn!(
iteration,
condition_number = c,
"Ill-conditioned Jacobian detected (κ > 1e10)"
);
}
}
}
tracing::debug!(iteration, "Fresh Jacobian computed");
} else {
frozen_count += 1;
tracing::debug!(iteration, frozen_count, "Reusing frozen Jacobian");
}
// Solve J·Δx = -r
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,
) {
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 {
apply_newton_step(&mut state, &delta, &clipping_mask, 1.0);
1.0
};
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()
.zip(prev_iteration_state.iter())
.map(|(s, p)| (s - p).powi(2))
.sum::<f64>()
.sqrt();
if current_norm < best_residual {
best_state.copy_from_slice(&state);
best_residual = current_norm;
tracing::debug!(iteration, best_residual, "Best state updated");
}
// 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 frozen_count > 0 || !force_recompute {
tracing::debug!(
iteration,
current_norm,
previous_norm,
"Unfreezing Jacobian"
);
}
force_recompute = true;
frozen_count = 0;
}
}
// Verbose mode: Log iteration residuals
if verbose_enabled && self.verbose_config.log_residuals {
tracing::info!(
iteration,
residual_norm = current_norm,
delta_norm = delta_norm,
alpha = alpha,
jacobian_frozen = jacobian_frozen_this_iter,
"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,
delta_norm,
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"
);
// Check convergence
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() {
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;
diag.final_residual = current_norm;
diag.best_residual = best_residual;
diag.converged = true;
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)"
);
let result = ConvergedState::with_report(
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
});
}
false
} else {
current_norm < self.tolerance
};
if 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;
diag.final_residual = current_norm;
diag.best_residual = best_residual;
diag.converged = true;
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"
);
let result = ConvergedState::new(
state,
iteration,
current_norm,
status,
SimulationMetadata::new(system.input_hash()),
);
return Ok(if let Some(d) = diagnostics {
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"
);
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
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"
);
Err(SolverError::NonConvergence {
iterations: self.max_iterations,
final_residual: current_norm,
}
.with_optional_diagnostics(failure_diagnostics))
}
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_newton_config_with_timeout() {
let cfg = NewtonConfig::default().with_timeout(Duration::from_millis(100));
assert_eq!(cfg.timeout, Some(Duration::from_millis(100)));
}
#[test]
fn test_newton_config_default() {
let cfg = NewtonConfig::default();
assert_eq!(cfg.max_iterations, 100);
assert!(cfg.tolerance > 0.0 && cfg.tolerance < 1e-3);
}
#[test]
fn test_newton_solver_trait_object() {
let mut boxed: Box<dyn Solver> = Box::new(NewtonConfig::default());
let mut system = System::new();
system.finalize().unwrap();
assert!(boxed.solve(&mut system).is_err());
}
}