#![allow(clippy::needless_range_loop)]
use super::verify::{eval_complex, C64, CONST_SETS, PARAM_SETS, X_SAMPLES};
use super::{contains, ddx, integrate_or_decline, simp, simp_plain, sub, AssumedSign, SolveCtx};
use crate::deriv::SideCondition;
use crate::eval::symbols::collect_free_symbols;
use crate::kernel::eval_const::try_expr_f64;
use crate::kernel::{ExprData, ExprId, ExprPool};
use crate::matrix::putzer::{self, Gap, GapPolicy};
use crate::matrix::zero_test::{zero_status, ZeroStatus};
use crate::matrix::Matrix;
use crate::ode::ODE;
use crate::simplify::assumptions::AssumptionContext;
use std::collections::{HashMap, HashSet};
use std::fmt;
#[derive(Clone, Debug)]
pub struct SystemSolution {
pub state_vars: Vec<ExprId>,
pub y_of_t: Vec<ExprId>,
pub constants: Vec<ExprId>,
pub fundamental_matrix: Vec<Vec<ExprId>>,
pub method: &'static str,
pub side_conditions: Vec<SideCondition>,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DsolveSystemError {
NotLinear(String),
NonConstantCoefficient(String),
UnsupportedSpectrum(String),
Unsupported(String),
VerificationFailed(String),
}
impl fmt::Display for DsolveSystemError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DsolveSystemError::NotLinear(m) => {
write!(f, "dsolve_system: system is not linear in the state: {m}")
}
DsolveSystemError::NonConstantCoefficient(m) => write!(
f,
"dsolve_system: coefficient depends on the independent variable: {m}"
),
DsolveSystemError::UnsupportedSpectrum(m) => {
write!(f, "dsolve_system: no closed-form spectrum: {m}")
}
DsolveSystemError::Unsupported(m) => write!(f, "dsolve_system: unsupported: {m}"),
DsolveSystemError::VerificationFailed(m) => {
write!(f, "dsolve_system: candidate failed verification: {m}")
}
}
}
}
impl std::error::Error for DsolveSystemError {}
impl crate::errors::AlkahestError for DsolveSystemError {
fn code(&self) -> &'static str {
match self {
DsolveSystemError::NotLinear(_) => "E-ODE-030",
DsolveSystemError::NonConstantCoefficient(_) => "E-ODE-031",
DsolveSystemError::UnsupportedSpectrum(_) => "E-ODE-032",
DsolveSystemError::Unsupported(_) => "E-ODE-033",
DsolveSystemError::VerificationFailed(_) => "E-ODE-034",
}
}
fn remediation(&self) -> Option<&'static str> {
match self {
DsolveSystemError::NotLinear(_) => Some(
"each right-hand side must be affine in the state variables; \
nonlinear systems have no closed-form matrix exponential",
),
DsolveSystemError::NonConstantCoefficient(_) => Some(
"y' = A(t)·y needs a Magnus/Peano series, not e^{At}; freeze the \
coefficients or use a numeric integrator",
),
DsolveSystemError::UnsupportedSpectrum(_) => Some(
"the characteristic polynomial of A has no closed-form roots here; \
substitute concrete values for the symbolic entries, or reduce the \
system to 2×2/3×3 or a triangular shape",
),
DsolveSystemError::Unsupported(_) => Some(
"a required integral of the forcing term did not close in elementary \
form; no unevaluated-integral output is returned",
),
DsolveSystemError::VerificationFailed(_) => Some(
"the solver found a candidate that did not verify by substitution; \
this is reported rather than returned as a (possibly wrong) answer",
),
}
}
}
pub fn dsolve_system(ode: &ODE, pool: &ExprPool) -> Result<SystemSolution, DsolveSystemError> {
dsolve_system_with(ode, &AssumptionContext::new(), pool)
}
pub fn dsolve_system_with(
ode: &ODE,
assumptions: &AssumptionContext,
pool: &ExprPool,
) -> Result<SystemSolution, DsolveSystemError> {
let mut ctx = SolveCtx {
assumptions,
conds: super::Conditions::default(),
};
solve_inner(ode, &mut ctx, pool)
}
fn extract_linear_system(
ode: &ODE,
pool: &ExprPool,
) -> Result<(Matrix, Vec<ExprId>), DsolveSystemError> {
let n = ode.state_vars.len();
let t = ode.time_var;
let mut rows: Vec<Vec<ExprId>> = Vec::with_capacity(n);
let mut forcing: Vec<ExprId> = Vec::with_capacity(n);
for &r in &ode.rhs {
let mut row = Vec::with_capacity(n);
for &v in &ode.state_vars {
let c = ddx(r, v, pool).map_err(|e| DsolveSystemError::NotLinear(e.to_string()))?;
for &w in &ode.state_vars {
if contains(c, w, pool) {
return Err(DsolveSystemError::NotLinear(format!(
"∂({})/∂({}) still depends on {}",
pool.display(r),
pool.display(v),
pool.display(w)
)));
}
}
if contains(c, t, pool) {
return Err(DsolveSystemError::NonConstantCoefficient(format!(
"∂({})/∂({}) = {} depends on {}",
pool.display(r),
pool.display(v),
pool.display(c),
pool.display(t)
)));
}
row.push(simp(c, pool));
}
let mut acc = r;
for (c, &v) in row.iter().zip(ode.state_vars.iter()) {
acc = sub(acc, simp(pool.mul(vec![*c, v]), pool), pool);
}
for &w in &ode.state_vars {
if contains(acc, w, pool) {
return Err(DsolveSystemError::NotLinear(format!(
"the state-independent remainder {} still depends on {}",
pool.display(acc),
pool.display(w)
)));
}
}
rows.push(row);
forcing.push(simp(acc, pool));
}
let a = Matrix::new(rows).map_err(|e| DsolveSystemError::NotLinear(e.to_string()))?;
Ok((a, forcing))
}
fn spectrum(a: &Matrix, pool: &ExprPool) -> Result<Vec<ExprId>, DsolveSystemError> {
let n = a.rows;
if a.cols != n {
return Err(DsolveSystemError::NotLinear(
"coefficient matrix is not square".to_string(),
));
}
if is_triangular(a, pool) {
return Ok((0..n).map(|i| simp(a.get(i, i), pool)).collect());
}
if n >= 3 && a.entries().iter().any(|&e| try_expr_f64(e, pool).is_none()) {
return Err(DsolveSystemError::UnsupportedSpectrum(format!(
"a {n}×{n} coefficient matrix with symbolic entries that is not triangular \
needs Cardano (or higher) radicals for its spectrum; those do not simplify, \
do not verify, and are not returned. Substitute concrete values for the \
symbolic entries, or write the system in triangular (compartment) form"
)));
}
let eigs = crate::matrix::eigenvalues(a, pool)
.map_err(|e| DsolveSystemError::UnsupportedSpectrum(e.to_string()))?;
let mut out = Vec::with_capacity(n);
for (lam, mult) in eigs {
for _ in 0..mult {
out.push(simp(lam, pool));
}
}
if out.len() != n {
return Err(DsolveSystemError::UnsupportedSpectrum(format!(
"eigenvalue multiplicities sum to {} for an {n}×{n} matrix; the \
characteristic polynomial did not split",
out.len()
)));
}
Ok(out)
}
fn confirm_spectrum(
a: &Matrix,
lambdas: &[ExprId],
pool: &ExprPool,
) -> Result<(), DsolveSystemError> {
match crate::matrix::spectrum::confirm_spectrum(a, lambdas, pool) {
Ok(check) if check.is_confirmed() => Ok(()),
Ok(_) => Err(DsolveSystemError::UnsupportedSpectrum(
"the eigenvalues of the coefficient matrix could not be evaluated at any \
sample, so nothing confirms they are its spectrum"
.to_string(),
)),
Err(refusal) => Err(DsolveSystemError::UnsupportedSpectrum(refusal.to_string())),
}
}
fn is_triangular(a: &Matrix, pool: &ExprPool) -> bool {
let n = a.rows;
let upper =
(0..n).all(|i| (0..i).all(|j| matches!(zero_status(pool, a.get(i, j)), ZeroStatus::Zero)));
let lower = (0..n)
.all(|i| ((i + 1)..n).all(|j| matches!(zero_status(pool, a.get(i, j)), ZeroStatus::Zero)));
upper || lower
}
struct DsolveGaps<'a, 'b> {
ctx: &'a mut SolveCtx<'b>,
}
impl GapPolicy for DsolveGaps<'_, '_> {
fn classify(&mut self, lambda: ExprId, mu: ExprId, pool: &ExprPool) -> Gap {
let d = putzer::normalised_gap(lambda, mu, pool);
if lambda == mu || matches!(zero_status(pool, d), ZeroStatus::Zero) {
return Gap::Confluent;
}
require_nonzero(d, self.ctx, pool);
Gap::Distinct(d)
}
}
fn matrix_exponential(
a: &Matrix,
lambdas: &[ExprId],
t: ExprId,
ctx: &mut SolveCtx<'_>,
pool: &ExprPool,
) -> Vec<Vec<ExprId>> {
let n = a.rows;
let phi = putzer::matrix_exponential(a, lambdas, t, &mut DsolveGaps { ctx }, pool)
.expect("a system with no state variables is rejected before this point");
(0..n)
.map(|i| (0..n).map(|j| phi.get(i, j)).collect())
.collect()
}
fn solve_inner(
ode: &ODE,
ctx: &mut SolveCtx<'_>,
pool: &ExprPool,
) -> Result<SystemSolution, DsolveSystemError> {
let n = ode.state_vars.len();
if n == 0 {
return Err(DsolveSystemError::NotLinear(
"the system has no state variables".to_string(),
));
}
if ode.rhs.len() != n {
return Err(DsolveSystemError::NotLinear(
"state variable and right-hand-side counts differ".to_string(),
));
}
let t = ode.time_var;
let (a, forcing) = extract_linear_system(ode, pool)?;
let lambdas = spectrum(&a, pool)?;
confirm_spectrum(&a, &lambdas, pool)?;
let phi = matrix_exponential(&a, &lambdas, t, ctx, pool);
let mut used: HashSet<String> = HashSet::new();
for &e in ode.rhs.iter().chain(ode.state_vars.iter()) {
collect_symbol_names(e, pool, &mut used);
}
let mut gen = super::ConstGen::with_used(used);
let constants: Vec<ExprId> = (0..n).map(|_| gen.fresh(pool)).collect();
let mut y: Vec<ExprId> = (0..n)
.map(|i| {
let terms: Vec<ExprId> = (0..n)
.map(|j| pool.mul(vec![phi[i][j], constants[j]]))
.collect();
simp(pool.add(terms), pool)
})
.collect();
let forced = forcing.iter().any(|&f| !is_zero(f, pool));
let method = if forced {
let phi_inv = negate_time(&phi, t, pool);
let mut integrated = Vec::with_capacity(n);
for row in &phi_inv {
let integrand = simp(
pool.add(
row.iter()
.zip(forcing.iter())
.map(|(&p, &f)| pool.mul(vec![p, f]))
.collect(),
),
pool,
);
let g = integrate_or_decline(integrand, t, pool)
.map_err(|e| DsolveSystemError::Unsupported(e.to_string()))?;
integrated.push(g);
}
for (i, yi) in y.iter_mut().enumerate() {
let terms: Vec<ExprId> = (0..n)
.map(|j| pool.mul(vec![phi[i][j], integrated[j]]))
.collect();
*yi = simp(pool.add(vec![*yi, pool.add(terms)]), pool);
}
"linear_system_putzer_variation_of_parameters"
} else {
"linear_system_putzer"
};
verify_system(ode, &y, &constants, pool)?;
Ok(SystemSolution {
state_vars: ode.state_vars.clone(),
y_of_t: y,
constants,
fundamental_matrix: phi,
method,
side_conditions: ctx.conds.side.clone(),
notes: ctx.conds.notes.clone(),
})
}
fn negate_time(phi: &[Vec<ExprId>], t: ExprId, pool: &ExprPool) -> Vec<Vec<ExprId>> {
let neg_t = pool.mul(vec![pool.integer(-1_i32), t]);
let mut m = HashMap::new();
m.insert(t, neg_t);
phi.iter()
.map(|row| {
row.iter()
.map(|&e| simp(crate::kernel::subs::subs(e, &m, pool), pool))
.collect()
})
.collect()
}
fn is_zero(e: ExprId, pool: &ExprPool) -> bool {
matches!(zero_status(pool, e), ZeroStatus::Zero)
}
fn collect_symbol_names(expr: ExprId, pool: &ExprPool, out: &mut HashSet<String>) {
pool.with(expr, |d| match d {
ExprData::Symbol { name, .. } => {
out.insert(name.clone());
}
ExprData::Add(args) | ExprData::Mul(args) | ExprData::Func { args, .. } => {
for &a in args {
collect_symbol_names(a, pool, out);
}
}
ExprData::Pow { base, exp } => {
collect_symbol_names(*base, pool, out);
collect_symbol_names(*exp, pool, out);
}
_ => {}
});
}
fn require_nonzero(e: ExprId, ctx: &mut SolveCtx<'_>, pool: &ExprPool) {
if super::is_settled_nonzero(e, pool) || ctx.assumed_sign(e, pool) != AssumedSign::Unknown {
return;
}
ctx.conds.require_nonzero(e);
ctx.conds.note(format!(
"two eigenvalues of the coefficient matrix differ by {}; where that vanishes \
the returned expression divides by zero. Its limit there exists and is the \
confluent (t·e^{{λt}}) form, but this solver does not take it — pass concrete \
values, or assert the difference is non-zero, to get an answer valid at that \
point",
pool.display(e)
));
}
const ZERO_TOL: f64 = 1e-9;
const REL_TOL: f64 = 1e-7;
const MIN_AGREEING: usize = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SampleOutcome {
Resolved,
CandidateNonFinite,
Unevaluable,
}
fn system_is_regular_at(ode: &ODE, env: &HashMap<ExprId, C64>, pool: &ExprPool) -> bool {
const PROBES: [f64; 3] = [1.0, 2.5, -1.5];
PROBES.iter().any(|&p| {
let mut e = env.clone();
for (k, &v) in ode.state_vars.iter().enumerate() {
e.insert(v, C64::real(p + 0.25 * k as f64));
}
ode.rhs
.iter()
.all(|&r| matches!(eval_complex(r, &e, pool), Some(v) if v.is_finite()))
})
}
fn verify_system(
ode: &ODE,
y: &[ExprId],
constants: &[ExprId],
pool: &ExprPool,
) -> Result<(), DsolveSystemError> {
let t = ode.time_var;
let mut subst: HashMap<ExprId, ExprId> = HashMap::new();
for (&v, &sol) in ode.state_vars.iter().zip(y.iter()) {
subst.insert(v, sol);
}
let mut residuals = Vec::with_capacity(y.len());
let mut derivs = Vec::with_capacity(y.len());
for (i, &sol) in y.iter().enumerate() {
let dy = ddx(sol, t, pool).map_err(|e| DsolveSystemError::Unsupported(e.to_string()))?;
derivs.push(dy);
let rhs = simp(crate::kernel::subs::subs(ode.rhs[i], &subst, pool), pool);
residuals.push(simp(sub(dy, rhs, pool), pool));
}
if residuals
.iter()
.all(|&r| is_literal_zero(r, pool) || is_literal_zero(simp_plain(r, pool), pool))
{
return Ok(());
}
let mut bound: Vec<ExprId> = vec![t];
bound.extend_from_slice(&ode.state_vars);
bound.extend_from_slice(constants);
let mut params: Vec<ExprId> = Vec::new();
for &r in &residuals {
collect_free_symbols(r, pool, &mut params);
}
params.retain(|s| !bound.contains(s));
params.sort_by_key(|&s| pool.display(s).to_string());
let param_sets = PARAM_SETS;
let const_sets = CONST_SETS;
let t_samples = X_SAMPLES;
let (mut agree, mut disagree, mut skipped) = (0usize, 0usize, 0usize);
let mut sets_agreeing = 0usize;
for ps in param_sets {
let mut env: HashMap<ExprId, C64> = HashMap::new();
for (i, &p) in params.iter().enumerate() {
env.insert(p, C64::real(ps[i % ps.len()]));
}
let before = agree;
for cs in const_sets {
for (i, &c) in constants.iter().enumerate() {
env.insert(c, C64::real(cs[i % cs.len()]));
}
for &tv in &t_samples {
env.insert(t, C64::real(tv));
let mut yv = Vec::with_capacity(y.len());
let mut dv = Vec::with_capacity(y.len());
let mut outcome = SampleOutcome::Resolved;
for i in 0..y.len() {
match (
eval_complex(y[i], &env, pool),
eval_complex(derivs[i], &env, pool),
) {
(Some(a), Some(b)) if a.is_finite() && b.is_finite() => {
yv.push(a);
dv.push(b);
}
(Some(_), Some(_)) => {
outcome = SampleOutcome::CandidateNonFinite;
break;
}
_ => {
outcome = SampleOutcome::Unevaluable;
break;
}
}
}
match outcome {
SampleOutcome::Resolved => {}
SampleOutcome::Unevaluable => {
skipped += 1;
continue;
}
SampleOutcome::CandidateNonFinite => {
if system_is_regular_at(ode, &env, pool) {
disagree += 1;
} else {
skipped += 1;
}
continue;
}
}
let mut rhs_env = env.clone();
for (k, &v) in ode.state_vars.iter().enumerate() {
rhs_env.insert(v, yv[k]);
}
let scale = yv
.iter()
.chain(dv.iter())
.fold(1.0_f64, |m, v| m.max(v.abs()));
for i in 0..y.len() {
match eval_complex(ode.rhs[i], &rhs_env, pool) {
Some(v) if v.is_finite() => {
let resid = dv[i].sub(v).abs();
if resid <= ZERO_TOL.max(REL_TOL * scale) {
agree += 1;
} else {
disagree += 1;
}
}
_ => skipped += 1,
}
}
}
}
if agree > before {
sets_agreeing += 1;
}
}
if disagree == 0 && agree >= MIN_AGREEING && sets_agreeing >= 2 {
return Ok(());
}
let mut shown = pool.display(residuals[0]).to_string();
if shown.len() > 400 {
shown.truncate(400);
shown.push_str(" …");
}
Err(DsolveSystemError::VerificationFailed(format!(
"samples: {agree} agreeing, {disagree} disagreeing, {skipped} unresolved \
({sets_agreeing} parameter sets agreeing); first residual: {shown}"
)))
}
fn is_literal_zero(e: ExprId, pool: &ExprPool) -> bool {
matches!(pool.get(e), ExprData::Integer(n) if n.0 == 0)
}
#[cfg(test)]
mod tests;