Skip to main content

qualia_core_db/solvers/calculus/
dense.rs

1//! General-dimension numerical methods (Calculus plan §4.4).
2//!
3//! The original solvers in [`super`] (`RungeKutta4Static`, `ShootingMethodBVP`,
4//! `SimpsonsIntegratorChunked`) are fixed at `[f64; 4]` / scalar — the same toy-sizing the
5//! linear-algebra and optimisation solvers once had. This module generalises them to
6//! **arbitrary state dimension** on heap `Vec<f64>`, reusing the engine's canonical dense LU
7//! solve ([`crate::solvers::linear_algebra::lu::lu_solve`]) for the BVP Newton correction —
8//! no re-implemented linear algebra. These are the allocate-friendly *authoring-path*
9//! versions; the zero-heap `[f64; 4]` solvers remain for the hot path.
10
11use crate::solvers::linear_algebra::lu::lu_solve;
12
13/// One classical RK4 step of `y' = f(t, y)` for a state of any dimension. `f` returns the
14/// derivative vector, which must have the same length as `y`.
15pub fn rk4_step<F>(f: &F, t: f64, y: &[f64], h: f64) -> Vec<f64>
16where
17    F: Fn(f64, &[f64]) -> Vec<f64>,
18{
19    let n = y.len();
20    let k1 = f(t, y);
21    let y2: Vec<f64> = (0..n).map(|i| y[i] + 0.5 * h * k1[i]).collect();
22    let k2 = f(t + 0.5 * h, &y2);
23    let y3: Vec<f64> = (0..n).map(|i| y[i] + 0.5 * h * k2[i]).collect();
24    let k3 = f(t + 0.5 * h, &y3);
25    let y4: Vec<f64> = (0..n).map(|i| y[i] + h * k3[i]).collect();
26    let k4 = f(t + h, &y4);
27    (0..n)
28        .map(|i| y[i] + h * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i]) / 6.0)
29        .collect()
30}
31
32/// Integrate `y' = f(t, y)` from `t0` to `t1` in `steps` equal RK4 steps; returns the final
33/// state. `steps` is clamped to `≥ 1`.
34pub fn rk4_integrate<F>(f: &F, t0: f64, y0: &[f64], t1: f64, steps: usize) -> Vec<f64>
35where
36    F: Fn(f64, &[f64]) -> Vec<f64>,
37{
38    let steps = steps.max(1);
39    let h = (t1 - t0) / steps as f64;
40    let mut y = y0.to_vec();
41    let mut t = t0;
42    for _ in 0..steps {
43        y = rk4_step(f, t, &y, h);
44        t += h;
45    }
46    y
47}
48
49/// Full trajectory `[(t, y)]`, including the initial point, over `steps` RK4 steps.
50pub fn rk4_solve<F>(f: &F, t0: f64, y0: &[f64], t1: f64, steps: usize) -> Vec<(f64, Vec<f64>)>
51where
52    F: Fn(f64, &[f64]) -> Vec<f64>,
53{
54    let steps = steps.max(1);
55    let h = (t1 - t0) / steps as f64;
56    let mut out = Vec::with_capacity(steps + 1);
57    let mut y = y0.to_vec();
58    let mut t = t0;
59    out.push((t, y.clone()));
60    for _ in 0..steps {
61        y = rk4_step(f, t, &y, h);
62        t += h;
63        out.push((t, y.clone()));
64    }
65    out
66}
67
68/// Composite Simpson's rule for a scalar integrand over `[a, b]` with `panels` subintervals
69/// (forced even, `≥ 2`). The general-`N` version of the fixed 100-chunk solver in [`super`].
70pub fn simpson<F: Fn(f64) -> f64>(f: &F, a: f64, b: f64, panels: usize) -> f64 {
71    let n = even_panels(panels);
72    let h = (b - a) / n as f64;
73    let mut sum = f(a) + f(b);
74    for i in 1..n {
75        let x = a + i as f64 * h;
76        sum += if i % 2 == 1 { 4.0 } else { 2.0 } * f(x);
77    }
78    sum * h / 3.0
79}
80
81/// Composite Simpson's rule for a **vector-valued** integrand `g: t → ℝᵏ`, integrated
82/// component-wise (e.g. a vector field along a parameter, or a state trajectory). The output
83/// length is the length of `g(a)`; `panels` is forced even and `≥ 2`.
84pub fn simpson_vec<F: Fn(f64) -> Vec<f64>>(g: &F, a: f64, b: f64, panels: usize) -> Vec<f64> {
85    let n = even_panels(panels);
86    let h = (b - a) / n as f64;
87    let ga = g(a);
88    let gb = g(b);
89    let k = ga.len();
90    let mut acc: Vec<f64> = (0..k).map(|j| ga[j] + gb[j]).collect();
91    for i in 1..n {
92        let x = a + i as f64 * h;
93        let w = if i % 2 == 1 { 4.0 } else { 2.0 };
94        let gx = g(x);
95        for j in 0..k {
96            acc[j] += w * gx[j];
97        }
98    }
99    acc.iter().map(|v| v * h / 3.0).collect()
100}
101
102fn even_panels(panels: usize) -> usize {
103    let n = panels.max(2);
104    if n % 2 == 0 {
105        n
106    } else {
107        n + 1
108    }
109}
110
111/// Shooting-method boundary-value solver for a first-order system `y' = f(t, y)` of dimension
112/// `n`. The components of the initial state listed in `free` are the unknowns; they are chosen
113/// by Newton's method so the user `residual(y(t1))` (length = `free.len()`) is driven to zero.
114/// The Jacobian is built by forward finite differences and solved with the canonical
115/// [`lu_solve`]. Returns the converged **initial** state, or `None` if it fails to converge
116/// within `max_iter` (or the Jacobian is singular).
117///
118/// This generalises the fixed `[f64; 4]` damped-update shooting solver to arbitrary state
119/// dimension and an arbitrary number of free initial conditions, with a real Newton step.
120pub fn shooting_bvp<F, R>(
121    f: &F,
122    t0: f64,
123    t1: f64,
124    steps: usize,
125    y0_init: &[f64],
126    free: &[usize],
127    residual: &R,
128    tol: f64,
129    max_iter: usize,
130) -> Option<Vec<f64>>
131where
132    F: Fn(f64, &[f64]) -> Vec<f64>,
133    R: Fn(&[f64]) -> Vec<f64>,
134{
135    let m = free.len();
136    if m == 0 || free.iter().any(|&i| i >= y0_init.len()) {
137        return None;
138    }
139    let mut y0 = y0_init.to_vec();
140
141    for _ in 0..max_iter {
142        let yf = rk4_integrate(f, t0, &y0, t1, steps);
143        let r = residual(&yf);
144        if r.len() != m {
145            return None;
146        }
147        let rnorm = r.iter().fold(0.0_f64, |a, &v| a.max(v.abs()));
148        if rnorm < tol {
149            return Some(y0);
150        }
151
152        // Finite-difference Jacobian J[i][j] = ∂rᵢ/∂(free param j).
153        let mut jac = vec![0.0; m * m];
154        for (j, &idx) in free.iter().enumerate() {
155            let h = 1e-6 * y0[idx].abs().max(1e-3);
156            let mut yp = y0.clone();
157            yp[idx] += h;
158            let yfp = rk4_integrate(f, t0, &yp, t1, steps);
159            let rp = residual(&yfp);
160            if rp.len() != m {
161                return None;
162            }
163            for i in 0..m {
164                jac[i * m + j] = (rp[i] - r[i]) / h;
165            }
166        }
167
168        // Solve J·Δ = −r and update the free components.
169        let neg_r: Vec<f64> = r.iter().map(|v| -v).collect();
170        let delta = lu_solve(m, &jac, &neg_r)?;
171        for (j, &idx) in free.iter().enumerate() {
172            y0[idx] += delta[j];
173        }
174    }
175    None
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use core::f64::consts::PI;
182
183    #[test]
184    fn rk4_solves_a_3d_linear_system() {
185        // y₀' = y₁, y₁' = -y₀ (harmonic), y₂' = -y₂ (decay). Start [0,1,1] at t=0.
186        // Exact at t = π/2: [sin, cos, e^{-t}] = [1, 0, e^{-π/2}].
187        let f = |_t: f64, y: &[f64]| vec![y[1], -y[0], -y[2]];
188        let yf = rk4_integrate(&f, 0.0, &[0.0, 1.0, 1.0], PI / 2.0, 2000);
189        assert!((yf[0] - 1.0).abs() < 1e-6, "y0 = {}", yf[0]);
190        assert!(yf[1].abs() < 1e-6, "y1 = {}", yf[1]);
191        assert!((yf[2] - (-PI / 2.0).exp()).abs() < 1e-6, "y2 = {}", yf[2]);
192    }
193
194    #[test]
195    fn rk4_trajectory_has_all_points() {
196        let f = |_t: f64, y: &[f64]| vec![-y[0]];
197        let traj = rk4_solve(&f, 0.0, &[1.0], 1.0, 10);
198        assert_eq!(traj.len(), 11);
199        assert_eq!(traj[0].0, 0.0);
200        assert!((traj.last().unwrap().1[0] - (-1.0_f64).exp()).abs() < 1e-6);
201    }
202
203    #[test]
204    fn simpson_scalar_and_vector() {
205        // ∫₀^π sin = 2 ; arbitrary (odd) panel count gets bumped to even.
206        assert!((simpson(&|x: f64| x.sin(), 0.0, PI, 999) - 2.0).abs() < 1e-6);
207        // Vector: ∫₀¹ [1, x, x²] = [1, 1/2, 1/3].
208        let v = simpson_vec(&|x: f64| vec![1.0, x, x * x], 0.0, 1.0, 100);
209        assert!((v[0] - 1.0).abs() < 1e-9);
210        assert!((v[1] - 0.5).abs() < 1e-9);
211        assert!((v[2] - 1.0 / 3.0).abs() < 1e-9);
212    }
213
214    #[test]
215    fn shooting_bvp_recovers_sine() {
216        // y'' = -y, y(0) = 0, y(π/2) = 1 → y(t) = sin(t), so the initial slope is 1.
217        // System: y₀' = y₁, y₁' = -y₀. Unknown = y₁(0) (index 1). Residual = y₀(π/2) − 1.
218        let f = |_t: f64, y: &[f64]| vec![y[1], -y[0]];
219        let residual = |yf: &[f64]| vec![yf[0] - 1.0];
220        let y0 = shooting_bvp(
221            &f,
222            0.0,
223            PI / 2.0,
224            400,
225            &[0.0, 0.0],
226            &[1],
227            &residual,
228            1e-10,
229            50,
230        )
231        .expect("BVP should converge");
232        assert!((y0[1] - 1.0).abs() < 1e-6, "initial slope = {}", y0[1]);
233        // The recovered trajectory hits the right boundary.
234        let yf = rk4_integrate(&f, 0.0, &y0, PI / 2.0, 400);
235        assert!((yf[0] - 1.0).abs() < 1e-8);
236    }
237
238    #[test]
239    fn shooting_bvp_rejects_bad_free_index() {
240        let f = |_t: f64, y: &[f64]| vec![y[1], -y[0]];
241        let residual = |yf: &[f64]| vec![yf[0] - 1.0];
242        assert!(shooting_bvp(&f, 0.0, 1.0, 10, &[0.0, 0.0], &[5], &residual, 1e-9, 10).is_none());
243    }
244}