Skip to main content

numra_fde/
l1_solver.rs

1//! L1 scheme solver for fractional differential equations.
2//!
3//! The L1 scheme discretizes the Caputo derivative as:
4//!
5//! ```text
6//! D^α y(tₙ) ≈ (Δt^(-α) / Γ(2-α)) Σⱼ₌₀ⁿ⁻¹ bⱼ (y(tₙ₋ⱼ) - y(tₙ₋ⱼ₋₁))
7//! ```
8//!
9//! For the FDE: D^α y = f(t, y), we get an implicit equation:
10//!
11//! ```text
12//! c * (yₙ - yₙ₋₁) + c * Σⱼ₌₁ⁿ⁻¹ bⱼ (yₙ₋ⱼ - yₙ₋ⱼ₋₁) = f(tₙ, yₙ)
13//! ```
14//!
15//! This is solved using fixed-point iteration (for explicit f) or Newton's method.
16//!
17//! Author: Moussa Leblouba
18//! Date: 5 March 2026
19//! Modified: 2 May 2026
20
21use crate::caputo::{caputo_weights, l1_coefficient};
22use crate::system::{FdeOptions, FdeResult, FdeSolver, FdeStats, FdeSystem};
23use numra_core::Scalar;
24
25/// L1 scheme solver for Caputo fractional differential equations.
26///
27/// Convergence order: O(Δt^(2-α))
28pub struct L1Solver;
29
30impl<S: Scalar> FdeSolver<S> for L1Solver {
31    fn solve<Sys: FdeSystem<S>>(
32        system: &Sys,
33        t0: S,
34        tf: S,
35        y0: &[S],
36        options: &FdeOptions<S>,
37    ) -> Result<FdeResult<S>, String> {
38        let dim = system.dim();
39        let alpha = system.alpha();
40
41        if !system.is_valid_order() {
42            return Err(format!(
43                "Invalid fractional order α = {}. Must be in (0, 1].",
44                alpha.to_f64()
45            ));
46        }
47
48        if y0.len() != dim {
49            return Err(format!(
50                "Initial state dimension {} doesn't match system dimension {}",
51                y0.len(),
52                dim
53            ));
54        }
55
56        let dt = options.dt;
57        let n_steps = ((tf - t0) / dt).to_f64().ceil() as usize;
58
59        if n_steps > options.max_steps {
60            return Err(format!(
61                "Required steps {} exceeds maximum {}",
62                n_steps, options.max_steps
63            ));
64        }
65
66        // Precompute L1 coefficient: c = Δt^(-α) / Γ(2-α)
67        let c = l1_coefficient(dt, alpha);
68
69        // Storage for solution history (needed for memory effect)
70        let mut y_history: Vec<Vec<S>> = Vec::with_capacity(n_steps + 1);
71        y_history.push(y0.to_vec());
72
73        let mut t_out = vec![t0];
74        let mut y_out = y0.to_vec();
75        let mut stats = FdeStats::default();
76
77        let mut f_buf = vec![S::ZERO; dim];
78
79        for n in 1..=n_steps {
80            let t = t0 + S::from_usize(n) * dt;
81
82            // Compute weights for this step
83            let b = caputo_weights(n, alpha);
84
85            // Compute the history sum: Σⱼ₌₁ⁿ⁻¹ bⱼ (yₙ₋ⱼ - yₙ₋ⱼ₋₁)
86            let mut history_sum = vec![S::ZERO; dim];
87            for j in 1..n {
88                let y_nmj = &y_history[n - j];
89                let y_nmjm1 = &y_history[n - j - 1];
90                for i in 0..dim {
91                    history_sum[i] += b[j] * (y_nmj[i] - y_nmjm1[i]);
92                }
93            }
94
95            // Get previous solution
96            let y_prev = &y_history[n - 1];
97
98            // Fixed-point iteration to solve:
99            // c * b[0] * (yₙ - yₙ₋₁) + c * history_sum = f(tₙ, yₙ)
100            // => yₙ = yₙ₋₁ + (f(tₙ, yₙ) - c * history_sum) / (c * b[0])
101            //
102            // For explicit f, we can use simple iteration.
103            // Start with predictor: yₙ ≈ yₙ₋₁
104            let mut y_new = y_prev.clone();
105
106            let c_b0 = c * b[0];
107            let inv_c_b0 = S::ONE / c_b0;
108
109            for _iter in 0..options.max_iter {
110                // Evaluate f at current guess
111                system.rhs(t, &y_new, &mut f_buf);
112                stats.n_rhs += 1;
113
114                // Compute new estimate
115                let mut y_next = vec![S::ZERO; dim];
116                let mut converged = true;
117
118                for i in 0..dim {
119                    let rhs = f_buf[i] - c * history_sum[i];
120                    y_next[i] = y_prev[i] + rhs * inv_c_b0;
121
122                    // Check convergence
123                    let diff = (y_next[i] - y_new[i]).abs();
124                    let scale = options.tol * (S::ONE + y_new[i].abs());
125                    if diff > scale {
126                        converged = false;
127                    }
128                }
129
130                y_new = y_next;
131
132                if converged {
133                    break;
134                }
135            }
136
137            // Store solution
138            y_history.push(y_new.clone());
139            t_out.push(t);
140            y_out.extend_from_slice(&y_new);
141            stats.n_steps += 1;
142        }
143
144        Ok(FdeResult::new(t_out, y_out, dim, stats))
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::caputo::mittag_leffler_1;
152
153    /// Fractional relaxation: D^α y = -λy, y(0) = 1
154    /// Exact solution: y(t) = E_α(-λ * t^α)
155    struct FractionalRelaxation {
156        lambda: f64,
157        alpha: f64,
158    }
159
160    impl FdeSystem<f64> for FractionalRelaxation {
161        fn dim(&self) -> usize {
162            1
163        }
164        fn alpha(&self) -> f64 {
165            self.alpha
166        }
167        fn rhs(&self, _t: f64, y: &[f64], f: &mut [f64]) {
168            f[0] = -self.lambda * y[0];
169        }
170    }
171
172    #[test]
173    fn test_l1_fractional_relaxation_alpha_1() {
174        // When α = 1, reduces to ordinary ODE: y' = -y, y(0) = 1
175        // Solution: y(t) = exp(-t)
176        let system = FractionalRelaxation {
177            lambda: 1.0,
178            alpha: 1.0,
179        };
180        let options = FdeOptions::default().dt(0.01);
181
182        let result = L1Solver::solve(&system, 0.0, 1.0, &[1.0], &options).expect("Solve failed");
183
184        assert!(result.success);
185        let y_final = result.y_final().unwrap()[0];
186        let y_exact = (-1.0_f64).exp();
187
188        // Should be close to exp(-1) ≈ 0.3679
189        assert!((y_final - y_exact).abs() < 0.01);
190    }
191
192    #[test]
193    fn test_l1_fractional_relaxation_alpha_half() {
194        // D^0.5 y = -y, y(0) = 1
195        // Solution: y(t) = E_{0.5}(-t^0.5)
196        let system = FractionalRelaxation {
197            lambda: 1.0,
198            alpha: 0.5,
199        };
200        let options = FdeOptions::default().dt(0.01);
201
202        let result = L1Solver::solve(&system, 0.0, 1.0, &[1.0], &options).expect("Solve failed");
203
204        assert!(result.success);
205
206        // Check a few points against Mittag-Leffler
207        for (i, &t) in result.t.iter().enumerate().step_by(10) {
208            if t > 0.0 {
209                let y_computed = result.y_at(i)[0];
210                let y_exact = mittag_leffler_1(-t.powf(0.5), 0.5);
211
212                // L1 scheme has order (2-α) = 1.5, so error ~ O(dt^1.5)
213                // With dt=0.01, expect error ~ 0.001
214                let error = (y_computed - y_exact).abs();
215                assert!(
216                    error < 0.05,
217                    "At t={}: computed={}, exact={}, error={}",
218                    t,
219                    y_computed,
220                    y_exact,
221                    error
222                );
223            }
224        }
225    }
226
227    #[test]
228    fn test_l1_2d_system() {
229        struct TwoD;
230        impl FdeSystem<f64> for TwoD {
231            fn dim(&self) -> usize {
232                2
233            }
234            fn alpha(&self) -> f64 {
235                0.8
236            }
237            fn rhs(&self, _t: f64, y: &[f64], f: &mut [f64]) {
238                f[0] = -y[0];
239                f[1] = -2.0 * y[1];
240            }
241        }
242
243        let options = FdeOptions::default().dt(0.01);
244        let result = L1Solver::solve(&TwoD, 0.0, 1.0, &[1.0, 1.0], &options).expect("Solve failed");
245
246        assert!(result.success);
247        let y_final = result.y_final().unwrap();
248
249        // Second component should decay faster
250        assert!(y_final[0] > y_final[1]);
251    }
252
253    #[test]
254    fn test_invalid_alpha() {
255        struct InvalidAlpha;
256        impl FdeSystem<f64> for InvalidAlpha {
257            fn dim(&self) -> usize {
258                1
259            }
260            fn alpha(&self) -> f64 {
261                1.5
262            } // Invalid: > 1
263            fn rhs(&self, _t: f64, _y: &[f64], f: &mut [f64]) {
264                f[0] = 0.0;
265            }
266        }
267
268        let options = FdeOptions::default();
269        let result = L1Solver::solve(&InvalidAlpha, 0.0, 1.0, &[1.0], &options);
270
271        assert!(result.is_err());
272    }
273
274    #[test]
275    fn test_l1_convergence_order() {
276        // D^0.5 y = -y, y(0) = 1. L1 has order 2-alpha = 1.5.
277        // Error ratio at halved dt should be ~2^1.5 ≈ 2.83.
278        let system = FractionalRelaxation {
279            lambda: 1.0,
280            alpha: 0.5,
281        };
282        let tf = 1.0;
283
284        // Coarse: dt = 0.02
285        let opts_coarse = FdeOptions::default().dt(0.02);
286        let res_coarse =
287            L1Solver::solve(&system, 0.0, tf, &[1.0], &opts_coarse).expect("Coarse solve failed");
288        let y_coarse = res_coarse.y_final().unwrap()[0];
289
290        // Fine: dt = 0.01
291        let opts_fine = FdeOptions::default().dt(0.01);
292        let res_fine =
293            L1Solver::solve(&system, 0.0, tf, &[1.0], &opts_fine).expect("Fine solve failed");
294        let y_fine = res_fine.y_final().unwrap()[0];
295
296        // Reference at very fine dt
297        let opts_ref = FdeOptions::default().dt(0.001);
298        let res_ref =
299            L1Solver::solve(&system, 0.0, tf, &[1.0], &opts_ref).expect("Ref solve failed");
300        let y_ref = res_ref.y_final().unwrap()[0];
301
302        let err_coarse = (y_coarse - y_ref).abs();
303        let err_fine = (y_fine - y_ref).abs();
304
305        let ratio = err_coarse / err_fine;
306        // Expected ratio ~ 2^1.5 ≈ 2.83, allow range [2.0, 4.0]
307        assert!(
308            ratio > 2.0 && ratio < 4.0,
309            "Convergence ratio: got {}, expected ~2.83 (errors: coarse={}, fine={})",
310            ratio,
311            err_coarse,
312            err_fine
313        );
314    }
315
316    #[test]
317    fn test_l1_alpha_near_1() {
318        // alpha = 0.99 should produce results close to exp(-t)
319        let system = FractionalRelaxation {
320            lambda: 1.0,
321            alpha: 0.99,
322        };
323        let options = FdeOptions::default().dt(0.01);
324
325        let result = L1Solver::solve(&system, 0.0, 1.0, &[1.0], &options).expect("Solve failed");
326
327        let y_final = result.y_final().unwrap()[0];
328        let y_exact = (-1.0_f64).exp();
329
330        assert!(
331            (y_final - y_exact).abs() < 0.05,
332            "alpha=0.99: got {}, expected ~{}",
333            y_final,
334            y_exact
335        );
336    }
337
338    #[test]
339    fn test_l1_zero_rhs() {
340        // D^alpha y = 0, y(0) = 5.0 => y(t) = 5.0 for all t
341        struct ZeroRhs;
342        impl FdeSystem<f64> for ZeroRhs {
343            fn dim(&self) -> usize {
344                1
345            }
346            fn alpha(&self) -> f64 {
347                0.7
348            }
349            fn rhs(&self, _t: f64, _y: &[f64], f: &mut [f64]) {
350                f[0] = 0.0;
351            }
352        }
353
354        let options = FdeOptions::default().dt(0.01);
355        let result = L1Solver::solve(&ZeroRhs, 0.0, 1.0, &[5.0], &options).expect("Solve failed");
356
357        for (i, &t) in result.t.iter().enumerate() {
358            let y = result.y_at(i)[0];
359            assert!(
360                (y - 5.0).abs() < 1e-10,
361                "At t={}: y should be 5.0, got {}",
362                t,
363                y
364            );
365        }
366    }
367
368    #[test]
369    fn test_l1_dimension_mismatch() {
370        // y0 has 2 elements but system has dim=1
371        let system = FractionalRelaxation {
372            lambda: 1.0,
373            alpha: 0.5,
374        };
375        let options = FdeOptions::default();
376
377        let result = L1Solver::solve(&system, 0.0, 1.0, &[1.0, 2.0], &options);
378        assert!(result.is_err(), "Should error on dimension mismatch");
379    }
380}