use crate::numerical::Nonlinear_systems::error::{SolveError, TerminationReason};
use crate::numerical::Nonlinear_systems::problem::{Bounds, JacobianProvider};
use log::{debug, info, warn};
use nalgebra::{DMatrix, DVector};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinearSolverKind {
Lu,
Inverse,
}
impl Default for LinearSolverKind {
fn default() -> Self {
Self::Lu
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EngineLogLevel {
Debug,
#[default]
Info,
Warn,
}
#[derive(Debug, Clone)]
pub struct DiagnosticsOptions {
pub collect_history: bool,
pub collect_statistics: bool,
pub enable_logging: bool,
pub log_level: EngineLogLevel,
pub enable_memory_diagnostics: bool,
}
impl Default for DiagnosticsOptions {
fn default() -> Self {
Self {
collect_history: true,
collect_statistics: true,
enable_logging: false,
log_level: EngineLogLevel::Info,
enable_memory_diagnostics: false,
}
}
}
#[derive(Debug, Clone)]
pub struct SolveOptions {
pub tolerance: f64,
pub max_iterations: usize,
pub linear_solver: LinearSolverKind,
pub bounds: Option<Bounds>,
pub diagnostics: DiagnosticsOptions,
}
impl Default for SolveOptions {
fn default() -> Self {
Self {
tolerance: 1e-6,
max_iterations: 100,
linear_solver: LinearSolverKind::Lu,
bounds: None,
diagnostics: DiagnosticsOptions::default(),
}
}
}
impl SolveOptions {
pub fn validate(&self, dimension: usize) -> Result<(), SolveError> {
if self.tolerance <= 0.0 {
return Err(SolveError::InvalidConfig(
"tolerance must be positive".to_string(),
));
}
if self.max_iterations == 0 {
return Err(SolveError::InvalidConfig(
"max_iterations must be greater than zero".to_string(),
));
}
if let Some(bounds) = &self.bounds {
if bounds.len() != dimension {
return Err(SolveError::DimensionMismatch {
expected: dimension,
actual: bounds.len(),
context: "solve options bounds",
});
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct IterationRecord {
pub iteration: usize,
pub residual_norm: f64,
pub step_norm: f64,
pub accepted: bool,
}
#[derive(Debug, Clone, Default)]
pub struct SolveStatistics {
pub iterations: usize,
pub residual_evaluations: usize,
pub jacobian_evaluations: usize,
pub linear_solves: usize,
pub accepted_steps: usize,
pub rejected_steps: usize,
}
#[derive(Debug, Clone, Default)]
pub struct MemoryDiagnostics {
pub solution_bytes: usize,
pub residual_bytes: usize,
pub jacobian_bytes: usize,
pub history_bytes: usize,
pub estimated_total_bytes: usize,
}
#[derive(Debug, Clone)]
pub struct SolveResult {
pub x: DVector<f64>,
pub residual: DVector<f64>,
pub residual_norm: f64,
pub iterations: usize,
pub termination: TerminationReason,
pub history: Vec<IterationRecord>,
pub statistics: SolveStatistics,
pub memory_diagnostics: Option<MemoryDiagnostics>,
}
#[derive(Debug, Clone)]
pub struct IterationState {
pub iteration: usize,
pub x: DVector<f64>,
pub residual: DVector<f64>,
pub jacobian: DMatrix<f64>,
pub residual_norm: f64,
}
#[derive(Debug, Clone)]
pub enum StepOutcome {
Continue {
next_x: DVector<f64>,
accepted: bool,
},
Converged,
Terminated(TerminationReason),
}
#[derive(Debug, Clone, Default)]
pub struct RuntimeDiagnostics {
pub linear_solves: usize,
pub accepted_steps: usize,
pub rejected_steps: usize,
}
pub trait NonlinearMethod {
type MethodState;
fn init<P: JacobianProvider>(
&self,
problem: &P,
x0: &DVector<f64>,
options: &SolveOptions,
residual: &DVector<f64>,
jacobian: &DMatrix<f64>,
) -> Result<Self::MethodState, SolveError>;
fn step<P: JacobianProvider>(
&self,
problem: &P,
state: &IterationState,
method_state: &mut Self::MethodState,
options: &SolveOptions,
runtime: &mut RuntimeDiagnostics,
) -> Result<StepOutcome, SolveError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NewtonMethod;
pub struct SolverEngine<M> {
method: M,
options: SolveOptions,
}
impl<M> SolverEngine<M> {
pub fn new(method: M, options: SolveOptions) -> Self {
Self { method, options }
}
}
impl<M: NonlinearMethod> SolverEngine<M> {
pub fn solve<P>(&self, problem: &P, x0: DVector<f64>) -> Result<SolveResult, SolveError>
where
P: JacobianProvider,
{
if problem.dimension() != x0.len() {
return Err(SolveError::DimensionMismatch {
expected: problem.dimension(),
actual: x0.len(),
context: "initial guess",
});
}
self.options.validate(problem.dimension())?;
if let Some(bounds) = &self.options.bounds {
bounds.validate(&x0)?;
}
let mut stats = SolveStatistics::default();
let mut history = Vec::with_capacity(self.options.max_iterations + 1);
let mut x = x0;
let mut residual = eval_residual(problem, &x, &mut stats)?;
let mut jacobian = eval_jacobian(problem, &x, &mut stats)?;
let mut residual_norm = residual.norm();
push_history(&self.options, &mut history, 0, residual_norm, 0.0, true);
if residual_norm < self.options.tolerance {
return Ok(build_result(
x,
residual,
jacobian,
residual_norm,
0,
TerminationReason::Converged,
history,
stats,
self.options.diagnostics.enable_memory_diagnostics,
));
}
log_message(
&self.options,
EngineLogLevel::Info,
"starting nonlinear solve",
);
let mut method_state =
self.method
.init(problem, &x, &self.options, &residual, &jacobian)?;
let mut runtime = RuntimeDiagnostics::default();
for iteration in 0..self.options.max_iterations {
log_message(
&self.options,
EngineLogLevel::Debug,
&format!("iteration {iteration}, residual = {:.6e}", residual_norm),
);
let state = IterationState {
iteration,
x: x.clone(),
residual: residual.clone(),
jacobian: jacobian.clone(),
residual_norm,
};
match self.method.step(
problem,
&state,
&mut method_state,
&self.options,
&mut runtime,
)? {
StepOutcome::Converged => {
stats.iterations = iteration;
merge_runtime(&mut stats, &runtime);
return Ok(build_result(
x,
residual,
jacobian,
residual_norm,
iteration,
TerminationReason::Converged,
history,
stats,
self.options.diagnostics.enable_memory_diagnostics,
));
}
StepOutcome::Terminated(reason) => {
stats.iterations = iteration;
merge_runtime(&mut stats, &runtime);
return Ok(build_result(
x,
residual,
jacobian,
residual_norm,
iteration,
reason,
history,
stats,
self.options.diagnostics.enable_memory_diagnostics,
));
}
StepOutcome::Continue { next_x, accepted } => {
let next_x = if let Some(bounds) = &self.options.bounds {
bounds.project(&next_x)
} else {
next_x
};
let step_norm = (&next_x - &x).norm();
if accepted {
x = next_x;
residual = eval_residual(problem, &x, &mut stats)?;
jacobian = eval_jacobian(problem, &x, &mut stats)?;
residual_norm = residual.norm();
}
push_history(
&self.options,
&mut history,
iteration + 1,
residual_norm,
step_norm,
accepted,
);
if residual_norm < self.options.tolerance {
stats.iterations = iteration + 1;
merge_runtime(&mut stats, &runtime);
return Ok(build_result(
x,
residual,
jacobian,
residual_norm,
iteration + 1,
TerminationReason::Converged,
history,
stats,
self.options.diagnostics.enable_memory_diagnostics,
));
}
}
}
}
stats.iterations = self.options.max_iterations;
merge_runtime(&mut stats, &runtime);
Ok(build_result(
x,
residual,
jacobian,
residual_norm,
self.options.max_iterations,
TerminationReason::MaxIterations,
history,
stats,
self.options.diagnostics.enable_memory_diagnostics,
))
}
}
impl NonlinearMethod for NewtonMethod {
type MethodState = ();
fn init<P: JacobianProvider>(
&self,
_problem: &P,
_x0: &DVector<f64>,
_options: &SolveOptions,
_residual: &DVector<f64>,
_jacobian: &DMatrix<f64>,
) -> Result<Self::MethodState, SolveError> {
Ok(())
}
fn step<P: JacobianProvider>(
&self,
_problem: &P,
state: &IterationState,
_method_state: &mut Self::MethodState,
options: &SolveOptions,
runtime: &mut RuntimeDiagnostics,
) -> Result<StepOutcome, SolveError> {
runtime.linear_solves += 1;
let step = solve_linear_system(options.linear_solver, &state.jacobian, &state.residual)?;
if step.norm() < options.tolerance {
return Ok(StepOutcome::Terminated(TerminationReason::StepTooSmall));
}
runtime.accepted_steps += 1;
Ok(StepOutcome::Continue {
next_x: &state.x - step,
accepted: true,
})
}
}
pub fn solve_linear_system(
solver: LinearSolverKind,
matrix: &DMatrix<f64>,
rhs: &DVector<f64>,
) -> Result<DVector<f64>, SolveError> {
match solver {
LinearSolverKind::Lu => matrix
.clone()
.lu()
.solve(rhs)
.ok_or(SolveError::SingularJacobian),
LinearSolverKind::Inverse => matrix
.clone()
.try_inverse()
.map(|inv| inv * rhs)
.ok_or(SolveError::SingularJacobian),
}
}
pub fn scaling_vector(jacobian: &DMatrix<f64>, use_column_scaling: bool) -> DVector<f64> {
if !use_column_scaling {
return DVector::from_element(jacobian.ncols(), 1.0);
}
let mut scaling = DVector::zeros(jacobian.ncols());
for column in 0..jacobian.ncols() {
let norm = jacobian.column(column).norm();
scaling[column] = if norm > 0.0 { norm } else { 1.0 };
}
scaling
}
pub fn scaled_norm(diag: &DVector<f64>, vector: &DVector<f64>) -> f64 {
diag.component_mul(vector).norm()
}
fn eval_residual<P: JacobianProvider>(
problem: &P,
x: &DVector<f64>,
stats: &mut SolveStatistics,
) -> Result<DVector<f64>, SolveError> {
stats.residual_evaluations += 1;
problem.residual(x)
}
fn eval_jacobian<P: JacobianProvider>(
problem: &P,
x: &DVector<f64>,
stats: &mut SolveStatistics,
) -> Result<DMatrix<f64>, SolveError> {
stats.jacobian_evaluations += 1;
problem.jacobian(x)
}
fn push_history(
options: &SolveOptions,
history: &mut Vec<IterationRecord>,
iteration: usize,
residual_norm: f64,
step_norm: f64,
accepted: bool,
) {
if options.diagnostics.collect_history {
history.push(IterationRecord {
iteration,
residual_norm,
step_norm,
accepted,
});
}
}
fn merge_runtime(stats: &mut SolveStatistics, runtime: &RuntimeDiagnostics) {
stats.linear_solves = runtime.linear_solves;
stats.accepted_steps = runtime.accepted_steps;
stats.rejected_steps = runtime.rejected_steps;
}
fn build_result(
x: DVector<f64>,
residual: DVector<f64>,
jacobian: DMatrix<f64>,
residual_norm: f64,
iterations: usize,
termination: TerminationReason,
history: Vec<IterationRecord>,
mut statistics: SolveStatistics,
with_memory: bool,
) -> SolveResult {
statistics.iterations = iterations;
let memory_diagnostics = with_memory.then(|| {
let solution_bytes = x.len() * std::mem::size_of::<f64>();
let residual_bytes = residual.len() * std::mem::size_of::<f64>();
let jacobian_bytes = jacobian.nrows() * jacobian.ncols() * std::mem::size_of::<f64>();
let history_bytes = std::mem::size_of_val(history.as_slice());
MemoryDiagnostics {
solution_bytes,
residual_bytes,
jacobian_bytes,
history_bytes,
estimated_total_bytes: solution_bytes + residual_bytes + jacobian_bytes + history_bytes,
}
});
SolveResult {
x,
residual,
residual_norm,
iterations,
termination,
history,
statistics,
memory_diagnostics,
}
}
fn log_message(options: &SolveOptions, level: EngineLogLevel, message: &str) {
if !options.diagnostics.enable_logging {
return;
}
match options.diagnostics.log_level {
EngineLogLevel::Debug => match level {
EngineLogLevel::Debug => debug!("{message}"),
EngineLogLevel::Info => info!("{message}"),
EngineLogLevel::Warn => warn!("{message}"),
},
EngineLogLevel::Info => match level {
EngineLogLevel::Debug => {}
EngineLogLevel::Info => info!("{message}"),
EngineLogLevel::Warn => warn!("{message}"),
},
EngineLogLevel::Warn => {
if matches!(level, EngineLogLevel::Warn) {
warn!("{message}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::numerical::Nonlinear_systems::problem::{JacobianProvider, NonlinearProblem};
struct ScalarQuadraticProblem;
struct CoupledPlainProblem;
impl NonlinearProblem for ScalarQuadraticProblem {
fn dimension(&self) -> usize {
1
}
fn residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
Ok(DVector::from_vec(vec![x[0] * x[0] - 2.0]))
}
}
impl JacobianProvider for ScalarQuadraticProblem {
fn jacobian(&self, x: &DVector<f64>) -> Result<DMatrix<f64>, SolveError> {
Ok(DMatrix::from_row_slice(1, 1, &[2.0 * x[0]]))
}
}
impl NonlinearProblem for CoupledPlainProblem {
fn dimension(&self) -> usize {
2
}
fn residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
Ok(DVector::from_vec(vec![
x[0] * x[0] + x[1] * x[1] - 1.0,
x[0] - x[1],
]))
}
}
impl JacobianProvider for CoupledPlainProblem {
fn jacobian(&self, x: &DVector<f64>) -> Result<DMatrix<f64>, SolveError> {
Ok(DMatrix::from_row_slice(
2,
2,
&[2.0 * x[0], 2.0 * x[1], 1.0, -1.0],
))
}
}
#[test]
fn newton_engine_converges_for_scalar_problem() {
let result = SolverEngine::new(NewtonMethod, SolveOptions::default())
.solve(&ScalarQuadraticProblem, DVector::from_vec(vec![1.5]))
.expect("solve");
assert_eq!(result.termination, TerminationReason::Converged);
assert!((result.x[0] - 2.0_f64.sqrt()).abs() < 1e-8);
assert!(result.statistics.linear_solves > 0);
}
#[test]
fn diagnostics_can_disable_history_and_collect_memory() {
let options = SolveOptions {
diagnostics: DiagnosticsOptions {
collect_history: false,
enable_memory_diagnostics: true,
..DiagnosticsOptions::default()
},
..SolveOptions::default()
};
let result = SolverEngine::new(NewtonMethod, options)
.solve(&ScalarQuadraticProblem, DVector::from_vec(vec![1.5]))
.expect("solve");
assert!(result.history.is_empty());
assert!(result.memory_diagnostics.is_some());
}
}