Skip to main content

numra_ide/
kernels.rs

1//! Memory kernels for integro-differential equations.
2//!
3//! Common kernel types used in viscoelasticity, heat conduction with memory,
4//! and other memory-dependent phenomena.
5//!
6//! Author: Moussa Leblouba
7//! Date: 5 March 2026
8//! Modified: 2 May 2026
9
10use numra_core::Scalar;
11
12/// Trait for memory kernels K(τ) where τ = t - s.
13///
14/// Convolution kernels depend only on the time difference, not absolute time.
15pub trait Kernel<S: Scalar>: Clone {
16    /// Evaluate the kernel at time lag τ.
17    fn evaluate(&self, tau: S) -> S;
18
19    /// Check if the kernel is a sum of exponentials (Prony series).
20    ///
21    /// Returns Some((amplitudes, rates)) if the kernel is SOE, None otherwise.
22    fn as_prony(&self) -> Option<(Vec<S>, Vec<S>)> {
23        None
24    }
25}
26
27/// Exponential kernel: K(τ) = a * exp(-b * τ).
28///
29/// Common in viscoelastic materials (Maxwell element).
30#[derive(Clone, Debug)]
31pub struct ExponentialKernel<S: Scalar> {
32    /// Amplitude
33    pub a: S,
34    /// Decay rate
35    pub b: S,
36}
37
38impl<S: Scalar> ExponentialKernel<S> {
39    pub fn new(a: S, b: S) -> Self {
40        Self { a, b }
41    }
42}
43
44impl<S: Scalar> Kernel<S> for ExponentialKernel<S> {
45    fn evaluate(&self, tau: S) -> S {
46        self.a * (-self.b * tau).exp()
47    }
48
49    fn as_prony(&self) -> Option<(Vec<S>, Vec<S>)> {
50        Some((vec![self.a], vec![self.b]))
51    }
52}
53
54/// Power-law kernel: K(τ) = a * τ^(-α).
55///
56/// Models fractional memory effects. Note: singular at τ = 0.
57#[derive(Clone, Debug)]
58pub struct PowerLawKernel<S: Scalar> {
59    /// Amplitude
60    pub a: S,
61    /// Exponent (0 < α < 1 for weak singularity)
62    pub alpha: S,
63}
64
65impl<S: Scalar> PowerLawKernel<S> {
66    pub fn new(a: S, alpha: S) -> Self {
67        Self { a, alpha }
68    }
69}
70
71impl<S: Scalar> Kernel<S> for PowerLawKernel<S> {
72    fn evaluate(&self, tau: S) -> S {
73        if tau <= S::ZERO {
74            S::ZERO // Regularization at τ = 0
75        } else {
76            self.a * tau.powf(-self.alpha)
77        }
78    }
79}
80
81/// Prony series (Sum of Exponentials) kernel.
82///
83/// K(τ) = Σᵢ aᵢ * exp(-bᵢ * τ)
84///
85/// This is the most efficient kernel type for numerical integration
86/// because it can be computed recursively without storing full history.
87#[derive(Clone, Debug)]
88pub struct PronyKernel<S: Scalar> {
89    /// Amplitudes aᵢ
90    pub amplitudes: Vec<S>,
91    /// Decay rates bᵢ
92    pub rates: Vec<S>,
93}
94
95impl<S: Scalar> PronyKernel<S> {
96    /// Create a new Prony series kernel.
97    ///
98    /// # Arguments
99    /// * `amplitudes` - Amplitudes aᵢ
100    /// * `rates` - Decay rates bᵢ (must be positive)
101    pub fn new(amplitudes: Vec<S>, rates: Vec<S>) -> Self {
102        assert_eq!(
103            amplitudes.len(),
104            rates.len(),
105            "Amplitudes and rates must have same length"
106        );
107        Self { amplitudes, rates }
108    }
109
110    /// Create a single exponential kernel.
111    pub fn single(a: S, b: S) -> Self {
112        Self {
113            amplitudes: vec![a],
114            rates: vec![b],
115        }
116    }
117
118    /// Create a two-term Prony series (generalized Maxwell model).
119    pub fn two_term(a1: S, b1: S, a2: S, b2: S) -> Self {
120        Self {
121            amplitudes: vec![a1, a2],
122            rates: vec![b1, b2],
123        }
124    }
125
126    /// Number of terms in the series.
127    pub fn num_terms(&self) -> usize {
128        self.amplitudes.len()
129    }
130}
131
132impl<S: Scalar> Kernel<S> for PronyKernel<S> {
133    fn evaluate(&self, tau: S) -> S {
134        let mut sum = S::ZERO;
135        for (a, b) in self.amplitudes.iter().zip(self.rates.iter()) {
136            sum += *a * (-*b * tau).exp();
137        }
138        sum
139    }
140
141    fn as_prony(&self) -> Option<(Vec<S>, Vec<S>)> {
142        Some((self.amplitudes.clone(), self.rates.clone()))
143    }
144}
145
146/// Mittag-Leffler kernel (for fractional derivatives).
147///
148/// K(τ) = τ^(β-1) * E_{α,β}(-λ * τ^α)
149///
150/// Reduces to exponential when α = β = 1.
151#[derive(Clone, Debug)]
152pub struct MittagLefflerKernel<S: Scalar> {
153    /// Rate parameter λ
154    pub lambda: S,
155    /// First ML parameter α
156    pub alpha: S,
157    /// Second ML parameter β
158    pub beta: S,
159}
160
161impl<S: Scalar> MittagLefflerKernel<S> {
162    pub fn new(lambda: S, alpha: S, beta: S) -> Self {
163        Self {
164            lambda,
165            alpha,
166            beta,
167        }
168    }
169
170    /// Standard relaxation kernel with α = β.
171    pub fn relaxation(lambda: S, alpha: S) -> Self {
172        Self {
173            lambda,
174            alpha,
175            beta: alpha,
176        }
177    }
178}
179
180impl<S: Scalar> Kernel<S> for MittagLefflerKernel<S> {
181    fn evaluate(&self, tau: S) -> S {
182        if tau <= S::ZERO {
183            return S::ZERO;
184        }
185
186        // Simple series approximation for E_{α,β}(z)
187        let z = -self.lambda * tau.powf(self.alpha);
188        let ml = mittag_leffler_approx(z, self.alpha, self.beta, 50);
189        tau.powf(self.beta - S::ONE) * ml
190    }
191}
192
193/// Approximate Mittag-Leffler function via series.
194fn mittag_leffler_approx<S: Scalar>(z: S, alpha: S, beta: S, max_terms: usize) -> S {
195    let tol = S::from_f64(1e-12);
196    let mut sum = S::ZERO;
197    let mut z_pow = S::ONE;
198
199    for k in 0..max_terms {
200        let gamma_arg = alpha * S::from_usize(k) + beta;
201        let gamma_val = gamma_approx(gamma_arg);
202        let term = z_pow / gamma_val;
203        sum += term;
204
205        if term.abs() < tol * sum.abs() && k > 0 {
206            break;
207        }
208        z_pow *= z;
209    }
210    sum
211}
212
213/// Lanczos approximation for gamma function.
214fn gamma_approx<S: Scalar>(z: S) -> S {
215    #[allow(clippy::excessive_precision)]
216    let p = [
217        0.99999999999980993,
218        676.5203681218851,
219        -1259.1392167224028,
220        771.32342877765313,
221        -176.61502916214059,
222        12.507343278686905,
223        -0.13857109526572012,
224        9.9843695780195716e-6,
225        1.5056327351493116e-7,
226    ];
227
228    let z_f = z.to_f64();
229    if z_f < 0.5 {
230        let pi = std::f64::consts::PI;
231        let sin_pz = (pi * z_f).sin();
232        let g1mz = gamma_approx(S::ONE - z);
233        return S::from_f64(pi) / (S::from_f64(sin_pz) * g1mz);
234    }
235
236    let z_f = z_f - 1.0;
237    let mut x = p[0];
238    for (i, coeff) in p.iter().enumerate().skip(1) {
239        x += coeff / (z_f + i as f64);
240    }
241
242    let t = z_f + 7.5;
243    let result = (2.0 * std::f64::consts::PI).sqrt() * t.powf(z_f + 0.5) * (-t).exp() * x;
244    S::from_f64(result)
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn test_exponential_kernel() {
253        let k = ExponentialKernel::new(2.0_f64, 0.5);
254
255        // K(0) = 2 * exp(0) = 2
256        assert!((k.evaluate(0.0) - 2.0).abs() < 1e-10);
257
258        // K(2) = 2 * exp(-1) ≈ 0.7358
259        let expected = 2.0 * (-1.0_f64).exp();
260        assert!((k.evaluate(2.0) - expected).abs() < 1e-10);
261
262        // Should be representable as Prony
263        let (a, b) = k.as_prony().unwrap();
264        assert_eq!(a.len(), 1);
265        assert!((a[0] - 2.0).abs() < 1e-10);
266        assert!((b[0] - 0.5).abs() < 1e-10);
267    }
268
269    #[test]
270    fn test_power_law_kernel() {
271        let k = PowerLawKernel::new(1.0_f64, 0.5);
272
273        // K(1) = 1 * 1^(-0.5) = 1
274        assert!((k.evaluate(1.0) - 1.0).abs() < 1e-10);
275
276        // K(4) = 1 * 4^(-0.5) = 0.5
277        assert!((k.evaluate(4.0) - 0.5).abs() < 1e-10);
278
279        // K(0) should be regularized to 0
280        assert!((k.evaluate(0.0) - 0.0).abs() < 1e-10);
281
282        // Not a Prony series
283        assert!(k.as_prony().is_none());
284    }
285
286    #[test]
287    fn test_prony_kernel() {
288        // Two-term Prony: K(τ) = 1*exp(-0.5τ) + 2*exp(-1.0τ)
289        let k = PronyKernel::two_term(1.0_f64, 0.5, 2.0, 1.0);
290
291        // K(0) = 1 + 2 = 3
292        assert!((k.evaluate(0.0) - 3.0).abs() < 1e-10);
293
294        // K(2) = exp(-1) + 2*exp(-2) ≈ 0.3679 + 0.2707 = 0.6386
295        let expected = (-1.0_f64).exp() + 2.0 * (-2.0_f64).exp();
296        assert!((k.evaluate(2.0) - expected).abs() < 1e-10);
297
298        assert_eq!(k.num_terms(), 2);
299    }
300
301    #[test]
302    fn test_prony_single() {
303        // PronyKernel::single(a, b) should match ExponentialKernel::new(a, b)
304        let prony = PronyKernel::single(2.0_f64, 0.5);
305        let exp_k = ExponentialKernel::new(2.0_f64, 0.5);
306
307        for tau in [0.0, 0.5, 1.0, 2.0, 5.0] {
308            let p_val = prony.evaluate(tau);
309            let e_val = exp_k.evaluate(tau);
310            assert!(
311                (p_val - e_val).abs() < 1e-10,
312                "Prony single should match ExponentialKernel at tau={}: {} vs {}",
313                tau,
314                p_val,
315                e_val
316            );
317        }
318    }
319
320    #[test]
321    fn test_power_law_negative_tau() {
322        let k = PowerLawKernel::new(1.0_f64, 0.5);
323
324        // tau < 0 should return 0 (regularization)
325        assert!((k.evaluate(-1.0)).abs() < 1e-10);
326        assert!((k.evaluate(-0.001)).abs() < 1e-10);
327    }
328
329    #[test]
330    fn test_kernel_clone() {
331        let exp_k = ExponentialKernel::new(2.0_f64, 0.5);
332        let exp_clone = exp_k.clone();
333        assert!((exp_clone.evaluate(1.0) - exp_k.evaluate(1.0)).abs() < 1e-15);
334
335        let pow_k = PowerLawKernel::new(1.0_f64, 0.5);
336        let pow_clone = pow_k.clone();
337        assert!((pow_clone.evaluate(4.0) - pow_k.evaluate(4.0)).abs() < 1e-15);
338
339        let prony_k = PronyKernel::two_term(1.0_f64, 0.5, 2.0, 1.0);
340        let prony_clone = prony_k.clone();
341        assert!((prony_clone.evaluate(1.0) - prony_k.evaluate(1.0)).abs() < 1e-15);
342
343        let ml_k = MittagLefflerKernel::new(1.0_f64, 1.0, 1.0);
344        let ml_clone = ml_k.clone();
345        assert!((ml_clone.evaluate(1.0) - ml_k.evaluate(1.0)).abs() < 1e-15);
346    }
347
348    #[test]
349    fn test_mittag_leffler_reduces_to_exponential() {
350        // E_{1,1}(-z) = exp(-z)
351        let ml_kernel = MittagLefflerKernel::new(1.0_f64, 1.0, 1.0);
352        let exp_kernel = ExponentialKernel::new(1.0_f64, 1.0);
353
354        for tau in [0.1, 0.5, 1.0, 2.0] {
355            let ml = ml_kernel.evaluate(tau);
356            let exp = exp_kernel.evaluate(tau);
357            assert!(
358                (ml - exp).abs() < 0.01,
359                "ML({}) = {}, exp = {}",
360                tau,
361                ml,
362                exp
363            );
364        }
365    }
366}