use nalgebra::{DMatrix, DVector};
use super::evaluate_derivative;
use crate::context::error::OxiflowError;
use crate::context::value::ContextValue;
use crate::context::ContextCalculator;
use crate::solver::linear::LinearSolver;
use crate::solver::scenario::Domain;
#[cfg(feature = "sparse")]
use crate::solver::sparse::SparseLinearSolver;
const FD_EPSILON: f64 = 1e-7;
pub(crate) fn finite_difference_jacobian(
domain: &Domain,
chain: &[&dyn ContextCalculator],
state: &ContextValue,
t: f64,
dt: f64,
) -> Result<DMatrix<f64>, OxiflowError> {
let base_field = state.as_scalar_field()?.clone();
let n = base_field.len();
let mut base_state = state.clone();
let f0 = evaluate_derivative(domain, chain, &mut base_state, t, dt)?;
let f0_field = f0.as_scalar_field()?.clone();
let mut jacobian = DMatrix::<f64>::zeros(n, n);
for j in 0..n {
let mut perturbed_field = base_field.clone();
perturbed_field[j] += FD_EPSILON;
let mut perturbed_state = ContextValue::ScalarField(perturbed_field);
let f_j = evaluate_derivative(domain, chain, &mut perturbed_state, t, dt)?;
let f_j_field = f_j.as_scalar_field()?;
for i in 0..n {
jacobian[(i, j)] = (f_j_field[i] - f0_field[i]) / FD_EPSILON;
}
}
Ok(jacobian)
}
pub(crate) fn theta_method_step(
domain: &Domain,
chain: &[&dyn ContextCalculator],
state: &mut ContextValue,
t: f64,
dt: f64,
theta: f64,
linear_solver: &dyn LinearSolver,
) -> Result<ContextValue, OxiflowError> {
let f_n = evaluate_derivative(domain, chain, state, t, dt)?;
let u_n_field = state.as_scalar_field()?.clone();
let f_n_field = f_n.as_scalar_field()?.clone();
let n = u_n_field.len();
let jacobian = finite_difference_jacobian(domain, chain, state, t + dt, dt)?;
let identity = DMatrix::<f64>::identity(n, n);
let system_matrix = identity - jacobian * (theta * dt);
let rhs = f_n_field * dt;
let delta_u = linear_solver.solve(&system_matrix, &rhs)?;
let u_next: DVector<f64> = u_n_field + delta_u;
Ok(ContextValue::ScalarField(u_next))
}
#[cfg(feature = "sparse")]
type SparseEntry = (usize, usize, f64);
#[cfg(feature = "sparse")]
type BandedJacobianResult = Result<(usize, Vec<SparseEntry>), OxiflowError>;
#[cfg(feature = "sparse")]
pub(crate) fn banded_jacobian_entries(
domain: &Domain,
chain: &[&dyn ContextCalculator],
state: &ContextValue,
t: f64,
dt: f64,
bandwidth: usize,
) -> BandedJacobianResult {
let base_field = state.as_scalar_field()?.clone();
let n = base_field.len();
let mut base_state = state.clone();
let f0 = evaluate_derivative(domain, chain, &mut base_state, t, dt)?;
let f0_field = f0.as_scalar_field()?.clone();
let n_colors = 2 * bandwidth + 1;
let mut triplets = Vec::new();
for color in 0..n_colors {
let columns: Vec<usize> = (color..n).step_by(n_colors).collect();
if columns.is_empty() {
continue;
}
let mut perturbed_field = base_field.clone();
for &j in &columns {
perturbed_field[j] += FD_EPSILON;
}
let mut perturbed_state = ContextValue::ScalarField(perturbed_field);
let f_c = evaluate_derivative(domain, chain, &mut perturbed_state, t, dt)?;
let f_c_field = f_c.as_scalar_field()?;
for &j in &columns {
let lo = j.saturating_sub(bandwidth);
let hi = (j + bandwidth + 1).min(n);
for i in lo..hi {
let deriv = (f_c_field[i] - f0_field[i]) / FD_EPSILON;
triplets.push((i, j, deriv));
}
}
}
Ok((n, triplets))
}
#[allow(dead_code)]
#[cfg(feature = "sparse")]
pub(crate) fn banded_finite_difference_jacobian(
domain: &Domain,
chain: &[&dyn ContextCalculator],
state: &ContextValue,
t: f64,
dt: f64,
bandwidth: usize,
) -> Result<faer::sparse::SparseColMat<usize, f64>, OxiflowError> {
let (n, triplets) = banded_jacobian_entries(domain, chain, state, t, dt, bandwidth)?;
let faer_triplets: Vec<faer::sparse::Triplet<usize, usize, f64>> = triplets
.into_iter()
.map(|(i, j, v)| faer::sparse::Triplet::new(i, j, v))
.collect();
faer::sparse::SparseColMat::try_new_from_triplets(n, n, &faer_triplets).map_err(|e| {
OxiflowError::PreconditionFailed {
context: "banded_finite_difference_jacobian",
message: format!("failed to build sparse matrix from triplets: {e:?}"),
}
})
}
#[cfg(feature = "sparse")]
#[allow(clippy::too_many_arguments)]
pub(crate) fn theta_method_step_adaptive(
domain: &Domain,
chain: &[&dyn ContextCalculator],
state: &mut ContextValue,
t: f64,
dt: f64,
theta: f64,
dense_solver: &dyn LinearSolver,
sparse_solver: &dyn SparseLinearSolver,
sparse_threshold: usize,
jacobian_bandwidth: Option<usize>,
) -> Result<ContextValue, OxiflowError> {
let n = state.as_scalar_field()?.len();
match jacobian_bandwidth {
Some(k) if n > sparse_threshold => {
let f_n = evaluate_derivative(domain, chain, state, t, dt)?;
let u_n_field = state.as_scalar_field()?.clone();
let f_n_field = f_n.as_scalar_field()?.clone();
let (n_check, raw_triplets) =
banded_jacobian_entries(domain, chain, state, t + dt, dt, k)?;
debug_assert_eq!(n_check, n);
let coeff = -theta * dt;
let system_triplets: Vec<faer::sparse::Triplet<usize, usize, f64>> = raw_triplets
.into_iter()
.map(|(i, j, deriv)| {
let mut value = coeff * deriv;
if i == j {
value += 1.0;
}
faer::sparse::Triplet::new(i, j, value)
})
.collect();
let system_matrix =
faer::sparse::SparseColMat::try_new_from_triplets(n, n, &system_triplets).map_err(
|e| OxiflowError::PreconditionFailed {
context: "theta_method_step_adaptive",
message: format!("failed to build sparse system matrix: {e:?}"),
},
)?;
let rhs = f_n_field * dt;
let delta_u = sparse_solver.solve(&system_matrix, &rhs)?;
let u_next: DVector<f64> = u_n_field + delta_u;
Ok(ContextValue::ScalarField(u_next))
}
_ => theta_method_step(domain, chain, state, t, dt, theta, dense_solver),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::compute::ComputeContext;
use crate::context::variable::ContextVariable;
use crate::mesh::{Mesh, UniformGrid1D};
use crate::model::traits::{PhysicalModel, RequiresContext};
use crate::solver::chain::build_calculator_chain;
use crate::solver::linear::NalgebraDenseSolver;
use crate::solver::scenario::Scenario;
#[derive(Debug)]
struct ExponentialDecay {
lambda: f64,
}
impl RequiresContext for ExponentialDecay {
fn required_variables(&self) -> Vec<ContextVariable> {
vec![]
}
}
impl PhysicalModel for ExponentialDecay {
fn compute_physics(
&self,
state: &ContextValue,
_ctx: &ComputeContext,
) -> Result<ContextValue, OxiflowError> {
let u = state.as_scalar_field()?;
Ok(ContextValue::ScalarField(u.map(|v| -self.lambda * v)))
}
fn initial_state(&self, mesh: &dyn Mesh) -> ContextValue {
ContextValue::ScalarField(DVector::from_element(mesh.n_dof(), 1.0))
}
fn name(&self) -> &str {
"exponential_decay"
}
}
fn make_mesh(n: usize) -> Box<dyn Mesh> {
Box::new(UniformGrid1D::new(n, 0.0, 1.0).unwrap())
}
#[test]
fn jacobian_of_linear_decay_is_minus_lambda_identity() {
let lambda = 2.5;
let scenario = Scenario::single(Box::new(ExponentialDecay { lambda }), make_mesh(3));
let domain = scenario.single_domain().unwrap();
let requirements = scenario.context_requirements();
let chain = build_calculator_chain(&requirements, &[]).unwrap();
let state = domain.model.initial_state(domain.mesh.as_ref());
let jac = finite_difference_jacobian(domain, &chain, &state, 0.0, 0.1).unwrap();
for i in 0..3 {
for j in 0..3 {
let expected = if i == j { -lambda } else { 0.0 };
assert!(
(jac[(i, j)] - expected).abs() < 1e-4,
"jac[{i},{j}] = {} (expected {expected})",
jac[(i, j)]
);
}
}
}
#[test]
fn backward_euler_theta_one_matches_analytical_for_linear_decay() {
let lambda = 3.0;
let dt = 0.1;
let scenario = Scenario::single(Box::new(ExponentialDecay { lambda }), make_mesh(2));
let domain = scenario.single_domain().unwrap();
let requirements = scenario.context_requirements();
let chain = build_calculator_chain(&requirements, &[]).unwrap();
let mut state = domain.model.initial_state(domain.mesh.as_ref());
let next = theta_method_step(
domain,
&chain,
&mut state,
0.0,
dt,
1.0,
&NalgebraDenseSolver,
)
.unwrap();
let expected = 1.0 / (1.0 + lambda * dt);
let field = next.as_scalar_field().unwrap();
for v in field.iter() {
assert!((v - expected).abs() < 1e-9, "got {v}, expected {expected}");
}
}
#[test]
fn crank_nicolson_theta_half_matches_analytical_for_linear_decay() {
let lambda = 3.0;
let dt = 0.1;
let scenario = Scenario::single(Box::new(ExponentialDecay { lambda }), make_mesh(2));
let domain = scenario.single_domain().unwrap();
let requirements = scenario.context_requirements();
let chain = build_calculator_chain(&requirements, &[]).unwrap();
let mut state = domain.model.initial_state(domain.mesh.as_ref());
let next = theta_method_step(
domain,
&chain,
&mut state,
0.0,
dt,
0.5,
&NalgebraDenseSolver,
)
.unwrap();
let expected = (1.0 - lambda * dt / 2.0) / (1.0 + lambda * dt / 2.0);
let field = next.as_scalar_field().unwrap();
for v in field.iter() {
assert!((v - expected).abs() < 1e-9, "got {v}, expected {expected}");
}
}
#[test]
fn backward_euler_stable_for_very_stiff_problem() {
let lambda = 1.0e4;
let dt = 0.1; let scenario = Scenario::single(Box::new(ExponentialDecay { lambda }), make_mesh(2));
let domain = scenario.single_domain().unwrap();
let requirements = scenario.context_requirements();
let chain = build_calculator_chain(&requirements, &[]).unwrap();
let mut state = domain.model.initial_state(domain.mesh.as_ref());
let next = theta_method_step(
domain,
&chain,
&mut state,
0.0,
dt,
1.0,
&NalgebraDenseSolver,
)
.unwrap();
let field = next.as_scalar_field().unwrap();
for v in field.iter() {
assert!(v.is_finite(), "value diverged: {v}");
assert!(v.abs() < 1.0, "expected strong damping, got {v}");
}
}
}
#[cfg(all(test, feature = "sparse"))]
mod sparse_tests {
use super::*;
use crate::context::compute::ComputeContext;
use crate::context::variable::ContextVariable;
use crate::mesh::{Mesh, UniformGrid1D};
use crate::model::traits::{PhysicalModel, RequiresContext};
use crate::solver::chain::build_calculator_chain;
use crate::solver::linear::NalgebraDenseSolver;
use crate::solver::scenario::Scenario;
use crate::solver::sparse::FaerSparseSolver;
#[derive(Debug)]
struct TridiagonalDiffusion {
diffusion: f64,
}
impl RequiresContext for TridiagonalDiffusion {
fn required_variables(&self) -> Vec<ContextVariable> {
vec![]
}
}
impl PhysicalModel for TridiagonalDiffusion {
fn compute_physics(
&self,
state: &ContextValue,
_ctx: &ComputeContext,
) -> Result<ContextValue, OxiflowError> {
let u = state.as_scalar_field()?;
let n = u.len();
let d = self.diffusion;
let out = DVector::from_fn(n, |i, _| {
let left = if i > 0 { u[i - 1] } else { 0.0 };
let right = if i + 1 < n { u[i + 1] } else { 0.0 };
d * (left - 2.0 * u[i] + right)
});
Ok(ContextValue::ScalarField(out))
}
fn initial_state(&self, mesh: &dyn Mesh) -> ContextValue {
let n = mesh.n_dof();
ContextValue::ScalarField(DVector::from_fn(
n,
|i, _| if i == n / 2 { 1.0 } else { 0.0 },
))
}
fn name(&self) -> &str {
"tridiagonal_diffusion_test"
}
}
struct PanicIfCalled;
impl SparseLinearSolver for PanicIfCalled {
fn solve(
&self,
_a: &faer::sparse::SparseColMat<usize, f64>,
_b: &DVector<f64>,
) -> Result<DVector<f64>, OxiflowError> {
panic!("sparse solver called when the dense path should have been taken");
}
}
fn make_mesh(n: usize) -> Box<dyn Mesh> {
Box::new(UniformGrid1D::new(n, 0.0, 1.0).unwrap())
}
#[test]
fn banded_jacobian_entries_match_dense_jacobian() {
let n = 12;
let scenario = Scenario::single(
Box::new(TridiagonalDiffusion { diffusion: 0.7 }),
make_mesh(n),
);
let domain = scenario.single_domain().unwrap();
let requirements = scenario.context_requirements();
let chain = build_calculator_chain(&requirements, &[]).unwrap();
let state = domain.model.initial_state(domain.mesh.as_ref());
let dense = finite_difference_jacobian(domain, &chain, &state, 0.0, 0.01).unwrap();
let (n_check, banded_triplets) =
banded_jacobian_entries(domain, &chain, &state, 0.0, 0.01, 1).unwrap();
assert_eq!(n_check, n);
for (i, j, v) in &banded_triplets {
assert!(
(dense[(*i, *j)] - v).abs() < 1e-6,
"mismatch at ({i},{j}): dense={}, banded={v}",
dense[(*i, *j)]
);
}
for i in 0..n {
for j in 0..n {
if i.abs_diff(j) > 1 {
assert!(
dense[(i, j)].abs() < 1e-6,
"unexpected nonzero outside the band at ({i},{j}): {}",
dense[(i, j)]
);
}
}
}
}
#[test]
fn adaptive_step_with_no_bandwidth_matches_theta_method_step_exactly() {
let n = 6;
let scenario = Scenario::single(
Box::new(TridiagonalDiffusion { diffusion: 0.5 }),
make_mesh(n),
);
let domain = scenario.single_domain().unwrap();
let requirements = scenario.context_requirements();
let chain = build_calculator_chain(&requirements, &[]).unwrap();
let mut state_a = domain.model.initial_state(domain.mesh.as_ref());
let mut state_b = state_a.clone();
let via_adaptive = theta_method_step_adaptive(
domain,
&chain,
&mut state_a,
0.0,
0.05,
1.0,
&NalgebraDenseSolver,
&PanicIfCalled,
100,
None, )
.unwrap();
let via_plain = theta_method_step(
domain,
&chain,
&mut state_b,
0.0,
0.05,
1.0,
&NalgebraDenseSolver,
)
.unwrap();
let a = via_adaptive.as_scalar_field().unwrap();
let b = via_plain.as_scalar_field().unwrap();
for i in 0..n {
assert!(
(a[i] - b[i]).abs() < 1e-12,
"mismatch at {i}: {} vs {}",
a[i],
b[i]
);
}
}
#[test]
fn small_system_stays_dense_even_with_bandwidth_declared() {
let n = 6; let scenario = Scenario::single(
Box::new(TridiagonalDiffusion { diffusion: 0.5 }),
make_mesh(n),
);
let domain = scenario.single_domain().unwrap();
let requirements = scenario.context_requirements();
let chain = build_calculator_chain(&requirements, &[]).unwrap();
let mut state = domain.model.initial_state(domain.mesh.as_ref());
let result = theta_method_step_adaptive(
domain,
&chain,
&mut state,
0.0,
0.05,
1.0,
&NalgebraDenseSolver,
&PanicIfCalled,
100,
Some(1),
)
.unwrap();
assert!(result
.as_scalar_field()
.unwrap()
.iter()
.all(|v| v.is_finite()));
}
#[test]
fn adaptive_step_with_bandwidth_matches_dense_result_above_threshold() {
let n = 40; let diffusion = 0.3;
let dt = 0.02;
let scenario_a =
Scenario::single(Box::new(TridiagonalDiffusion { diffusion }), make_mesh(n));
let domain_a = scenario_a.single_domain().unwrap();
let requirements_a = scenario_a.context_requirements();
let chain_a = build_calculator_chain(&requirements_a, &[]).unwrap();
let mut state_a = domain_a.model.initial_state(domain_a.mesh.as_ref());
let scenario_b =
Scenario::single(Box::new(TridiagonalDiffusion { diffusion }), make_mesh(n));
let domain_b = scenario_b.single_domain().unwrap();
let requirements_b = scenario_b.context_requirements();
let chain_b = build_calculator_chain(&requirements_b, &[]).unwrap();
let mut state_b = domain_b.model.initial_state(domain_b.mesh.as_ref());
let via_sparse = theta_method_step_adaptive(
domain_a,
&chain_a,
&mut state_a,
0.0,
dt,
1.0,
&NalgebraDenseSolver,
&FaerSparseSolver,
10, Some(1),
)
.unwrap();
let via_dense = theta_method_step(
domain_b,
&chain_b,
&mut state_b,
0.0,
dt,
1.0,
&NalgebraDenseSolver,
)
.unwrap();
let a = via_sparse.as_scalar_field().unwrap();
let b = via_dense.as_scalar_field().unwrap();
for i in 0..n {
assert!(
(a[i] - b[i]).abs() < 1e-6,
"sparse/dense mismatch at {i}: {} vs {}",
a[i],
b[i]
);
}
}
}