Skip to main content

oxiphysics_core/
numerical_integration.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Numerical integration methods.
6//!
7//! Provides composite trapezoid, composite Simpson, Romberg integration,
8//! Gauss-Legendre quadrature (5-point and variable-point), adaptive Simpson,
9//! Monte Carlo integration, Clenshaw-Curtis, double-exponential (tanh-sinh),
10//! and multidimensional trapezoid rules.
11
12#![allow(dead_code)]
13#![allow(clippy::too_many_arguments)]
14
15use std::f64::consts::PI;
16
17// ─────────────────────────────────────────────────────────────────────────────
18// Composite trapezoid rule
19// ─────────────────────────────────────────────────────────────────────────────
20
21/// Approximate ∫ f(x) dx on \[a, b\] using the composite trapezoid rule with `n` subintervals.
22///
23/// Uses the formula: h/2 * (f(a) + 2*f(x_1) + ... + 2*f(x_{n-1}) + f(b)).
24pub fn trapezoid_rule(f: impl Fn(f64) -> f64, a: f64, b: f64, n: usize) -> f64 {
25    assert!(n >= 1, "n must be at least 1");
26    let h = (b - a) / n as f64;
27    let mut sum = f(a) + f(b);
28    for i in 1..n {
29        sum += 2.0 * f(a + i as f64 * h);
30    }
31    sum * h / 2.0
32}
33
34// ─────────────────────────────────────────────────────────────────────────────
35// Composite Simpson's rule
36// ─────────────────────────────────────────────────────────────────────────────
37
38/// Approximate ∫ f(x) dx on \[a, b\] using composite Simpson's rule with `n` subintervals.
39///
40/// `n` must be even. Uses the 1/3 Simpson formula with alternating weights 4, 2.
41pub fn simpson_rule(f: impl Fn(f64) -> f64, a: f64, b: f64, n: usize) -> f64 {
42    let n = if !n.is_multiple_of(2) { n + 1 } else { n };
43    assert!(n >= 2);
44    let h = (b - a) / n as f64;
45    let mut sum = f(a) + f(b);
46    for i in 1..n {
47        let x = a + i as f64 * h;
48        sum += if i % 2 == 0 { 2.0 } else { 4.0 } * f(x);
49    }
50    sum * h / 3.0
51}
52
53// ─────────────────────────────────────────────────────────────────────────────
54// Romberg integration
55// ─────────────────────────────────────────────────────────────────────────────
56
57/// Approximate ∫ f(x) dx on \[a, b\] using Romberg integration (Richardson extrapolation).
58///
59/// Builds a triangular table of `max_levels` rows; returns the best estimate.
60/// Stops early if consecutive diagonal entries agree within `tol`.
61pub fn romberg_integration(
62    f: impl Fn(f64) -> f64,
63    a: f64,
64    b: f64,
65    max_levels: usize,
66    tol: f64,
67) -> f64 {
68    let max_levels = max_levels.max(1);
69    let mut r = vec![vec![0.0_f64; max_levels]; max_levels];
70    r[0][0] = trapezoid_rule(&f, a, b, 1);
71    for i in 1..max_levels {
72        let n_steps = 1usize << i; // 2^i trapezoid steps
73        r[i][0] = trapezoid_rule(&f, a, b, n_steps);
74        for j in 1..=i {
75            let factor = (4u64.pow(j as u32)) as f64;
76            r[i][j] = (factor * r[i][j - 1] - r[i - 1][j - 1]) / (factor - 1.0);
77        }
78        if i >= 1 && (r[i][i] - r[i - 1][i - 1]).abs() < tol {
79            return r[i][i];
80        }
81    }
82    r[max_levels - 1][max_levels - 1]
83}
84
85// ─────────────────────────────────────────────────────────────────────────────
86// 5-point Gauss-Legendre
87// ─────────────────────────────────────────────────────────────────────────────
88
89/// Approximate ∫ f(x) dx on \[a, b\] using the 5-point Gauss-Legendre formula.
90///
91/// Exact for polynomials of degree ≤ 9.
92pub fn gauss_legendre_5pt(f: impl Fn(f64) -> f64, a: f64, b: f64) -> f64 {
93    // Standard 5-point nodes and weights on [-1, 1]
94    let nodes = [
95        -0.906_179_845_938_664,
96        -0.538_469_310_105_683,
97        0.0,
98        0.538_469_310_105_683,
99        0.906_179_845_938_664,
100    ];
101    let weights = [
102        0.236_926_885_056_189,
103        0.478_628_670_499_366,
104        0.568_888_888_888_889,
105        0.478_628_670_499_366,
106        0.236_926_885_056_189,
107    ];
108    let mid = (a + b) / 2.0;
109    let half = (b - a) / 2.0;
110    let mut sum = 0.0;
111    for (xi, wi) in nodes.iter().zip(weights.iter()) {
112        sum += wi * f(mid + half * xi);
113    }
114    sum * half
115}
116
117// ─────────────────────────────────────────────────────────────────────────────
118// Gauss-Legendre nodes and weights (n = 2..5)
119// ─────────────────────────────────────────────────────────────────────────────
120
121/// Return the Gauss-Legendre nodes and weights for `n` points on `[-1, 1]`.
122///
123/// Supported values of `n`: 2, 3, 4, 5. Panics for other values.
124/// Returns `(nodes, weights)` both of length `n`.
125pub fn gauss_legendre_nodes_weights(n: usize) -> (Vec<f64>, Vec<f64>) {
126    match n {
127        2 => (
128            vec![-0.577_350_269_189_626, 0.577_350_269_189_626],
129            vec![1.0, 1.0],
130        ),
131        3 => (
132            vec![-0.774_596_669_241_483, 0.0, 0.774_596_669_241_483],
133            vec![
134                0.555_555_555_555_556,
135                0.888_888_888_888_889,
136                0.555_555_555_555_556,
137            ],
138        ),
139        4 => (
140            vec![
141                -0.861_136_311_594_953,
142                -0.339_981_043_584_856,
143                0.339_981_043_584_856,
144                0.861_136_311_594_953,
145            ],
146            vec![
147                0.347_854_845_137_454,
148                0.652_145_154_862_546,
149                0.652_145_154_862_546,
150                0.347_854_845_137_454,
151            ],
152        ),
153        5 => {
154            let (nodes, weights) = (
155                vec![
156                    -0.906_179_845_938_664,
157                    -0.538_469_310_105_683,
158                    0.0,
159                    0.538_469_310_105_683,
160                    0.906_179_845_938_664,
161                ],
162                vec![
163                    0.236_926_885_056_189,
164                    0.478_628_670_499_366,
165                    0.568_888_888_888_889,
166                    0.478_628_670_499_366,
167                    0.236_926_885_056_189,
168                ],
169            );
170            (nodes, weights)
171        }
172        _ => panic!("gauss_legendre_nodes_weights: n must be 2, 3, 4, or 5"),
173    }
174}
175
176// ─────────────────────────────────────────────────────────────────────────────
177// Adaptive Simpson
178// ─────────────────────────────────────────────────────────────────────────────
179
180/// Approximate ∫ f(x) dx on \[a, b\] using recursive adaptive Simpson's rule.
181///
182/// Recursively subdivides if the local error estimate exceeds `tol`.
183/// Recursion depth is limited to `max_depth` to prevent stack overflow.
184pub fn adaptive_simpson(f: impl Fn(f64) -> f64, a: f64, b: f64, tol: f64, max_depth: usize) -> f64 {
185    fn simpson_one(f: &impl Fn(f64) -> f64, a: f64, b: f64) -> f64 {
186        let c = (a + b) / 2.0;
187        (b - a) / 6.0 * (f(a) + 4.0 * f(c) + f(b))
188    }
189
190    fn recurse(f: &impl Fn(f64) -> f64, a: f64, b: f64, tol: f64, whole: f64, depth: usize) -> f64 {
191        let c = (a + b) / 2.0;
192        let left = simpson_one(f, a, c);
193        let right = simpson_one(f, c, b);
194        let delta = left + right - whole;
195        if depth == 0 || delta.abs() < 15.0 * tol {
196            left + right + delta / 15.0
197        } else {
198            let half_tol = tol / 2.0;
199            recurse(f, a, c, half_tol, left, depth - 1)
200                + recurse(f, c, b, half_tol, right, depth - 1)
201        }
202    }
203
204    let whole = simpson_one(&f, a, b);
205    recurse(&f, a, b, tol, whole, max_depth)
206}
207
208// ─────────────────────────────────────────────────────────────────────────────
209// Monte Carlo integration
210// ─────────────────────────────────────────────────────────────────────────────
211
212/// Approximate ∫ f(x) dx on \[a, b\] using Monte Carlo sampling with `n_samples` points.
213///
214/// Uses a fixed seed via `rand::rng()` for reproducibility within a run.
215pub fn monte_carlo_integrate(f: impl Fn(f64) -> f64, a: f64, b: f64, n_samples: usize) -> f64 {
216    use rand::RngExt;
217    let mut rng = rand::rng();
218    let mut sum = 0.0;
219    for _ in 0..n_samples {
220        let x: f64 = rng.random_range(a..b);
221        sum += f(x);
222    }
223    (b - a) * sum / n_samples as f64
224}
225
226// ─────────────────────────────────────────────────────────────────────────────
227// Clenshaw-Curtis quadrature
228// ─────────────────────────────────────────────────────────────────────────────
229
230/// Approximate ∫_{-1}^{1} f(x) dx using Clenshaw-Curtis quadrature with `n+1` points.
231///
232/// Uses Chebyshev nodes cos(k*π/n) and weights derived from the DCT.
233pub fn clenshaw_curtis(f: impl Fn(f64) -> f64, n: usize) -> f64 {
234    let n = n.max(1);
235    // Chebyshev nodes: x_k = cos(k*pi/n), k = 0..=n
236    let vals: Vec<f64> = (0..=n)
237        .map(|k| f((k as f64 * PI / n as f64).cos()))
238        .collect();
239
240    // Weights via exact formula for Clenshaw-Curtis
241    let mut sum = 0.0;
242    let nf = n as f64;
243    for j in 0..=n {
244        let cj = if j == 0 || j == n { 1.0 } else { 2.0 };
245        let mut w = 0.0_f64;
246        for k in (0..=(n / 2)).map(|m| 2 * m) {
247            let bk = if k == 0 { 1.0 } else { 2.0 };
248            let denom = 1.0 - (k as f64).powi(2);
249            if denom.abs() < 1e-14 {
250                continue;
251            }
252            let cos_term = ((k * j) as f64 * PI / nf).cos();
253            w += bk / denom * cos_term;
254        }
255        w /= nf;
256        sum += cj * w * vals[j];
257    }
258    sum
259}
260
261// ─────────────────────────────────────────────────────────────────────────────
262// Double-exponential (tanh-sinh) quadrature
263// ─────────────────────────────────────────────────────────────────────────────
264
265/// Approximate ∫ f(x) dx on \[a, b\] using the tanh-sinh (double exponential) transform.
266///
267/// Uses `n` quadrature points spread symmetrically about the midpoint.
268pub fn double_exponential(f: impl Fn(f64) -> f64, a: f64, b: f64, n: usize) -> f64 {
269    let n = n.max(2);
270    let half_n = n as i64 / 2;
271    let h = 3.0 / (half_n as f64); // step size in transformed variable t
272    let mid = (a + b) / 2.0;
273    let half = (b - a) / 2.0;
274    let mut sum = 0.0;
275    for ki in (-half_n)..=half_n {
276        let t = ki as f64 * h;
277        let sinh_t = t.sinh();
278        let cosh_t = t.cosh();
279        let cosh_sinh = (PI / 2.0 * sinh_t).cosh();
280        let x = mid + half * (PI / 2.0 * sinh_t).tanh();
281        let w = half * PI / 2.0 * cosh_t / (cosh_sinh * cosh_sinh);
282        sum += w * f(x);
283    }
284    sum * h
285}
286
287// ─────────────────────────────────────────────────────────────────────────────
288// Multidimensional trapezoid rule
289// ─────────────────────────────────────────────────────────────────────────────
290
291/// Approximate ∫ f(x) dx over a hyperrectangle using the composite trapezoid rule.
292///
293/// `bounds` is a slice of `(a_i, b_i)` pairs, one per dimension.
294/// `n` is the number of subintervals per dimension.
295pub fn multidimensional_trap(f: impl Fn(&[f64]) -> f64, bounds: &[(f64, f64)], n: usize) -> f64 {
296    let dim = bounds.len();
297    if dim == 0 {
298        return f(&[]);
299    }
300    let n = n.max(1);
301
302    // Generate all grid points via iterative expansion
303    let steps_per_dim: Vec<f64> = bounds.iter().map(|(a, b)| (b - a) / n as f64).collect();
304    let total_points = (n + 1).pow(dim as u32);
305
306    let mut integral = 0.0;
307    let mut point = vec![0.0_f64; dim];
308
309    for idx in 0..total_points {
310        // Decode multi-index: for dimension d, the index is (idx / (n+1)^d) % (n+1)
311        let mut weight = 1.0_f64;
312        for d in 0..dim {
313            let stride = (n + 1).pow(d as u32);
314            let i_d = (idx / stride) % (n + 1);
315            point[d] = bounds[d].0 + i_d as f64 * steps_per_dim[d];
316            let w = if i_d == 0 || i_d == n { 1.0 } else { 2.0 };
317            weight *= w;
318        }
319        integral += weight * f(&point);
320    }
321
322    // Multiply by h/2 for each dimension
323    let mut factor = integral;
324    for (a, b) in bounds.iter() {
325        factor *= (b - a) / (2.0 * n as f64);
326    }
327    factor
328}
329
330// ─────────────────────────────────────────────────────────────────────────────
331// QuadratureRule
332// ─────────────────────────────────────────────────────────────────────────────
333
334/// A generic quadrature rule defined by nodes and weights on a reference interval.
335///
336/// The nodes and weights are defined on `[-1, 1]`; `apply` rescales to `[a, b]`.
337#[derive(Debug, Clone)]
338pub struct QuadratureRule {
339    /// Quadrature nodes on `[-1, 1]`.
340    pub nodes: Vec<f64>,
341    /// Corresponding quadrature weights.
342    pub weights: Vec<f64>,
343    /// Human-readable name (e.g., "Gauss-Legendre-5").
344    pub name: String,
345}
346
347impl QuadratureRule {
348    /// Construct a new quadrature rule.
349    pub fn new(nodes: Vec<f64>, weights: Vec<f64>, name: impl Into<String>) -> Self {
350        assert_eq!(nodes.len(), weights.len(), "nodes and weights must match");
351        Self {
352            nodes,
353            weights,
354            name: name.into(),
355        }
356    }
357
358    /// Apply the quadrature rule to approximate ∫ f(x) dx on \[a, b\].
359    pub fn apply(&self, f: impl Fn(f64) -> f64, a: f64, b: f64) -> f64 {
360        let mid = (a + b) / 2.0;
361        let half = (b - a) / 2.0;
362        let mut sum = 0.0;
363        for (xi, wi) in self.nodes.iter().zip(self.weights.iter()) {
364            sum += wi * f(mid + half * xi);
365        }
366        sum * half
367    }
368
369    /// Number of quadrature points.
370    pub fn n_points(&self) -> usize {
371        self.nodes.len()
372    }
373}
374
375// ─────────────────────────────────────────────────────────────────────────────
376// Tests
377// ─────────────────────────────────────────────────────────────────────────────
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    const TOL: f64 = 1e-6;
384
385    // ── trapezoid ─────────────────────────────────────────────────────────────
386
387    #[test]
388    fn test_trapezoid_constant() {
389        // ∫ 3 dx on [0, 1] = 3
390        let result = trapezoid_rule(|_| 3.0, 0.0, 1.0, 100);
391        assert!((result - 3.0).abs() < 1e-12);
392    }
393
394    #[test]
395    fn test_trapezoid_linear() {
396        // ∫ x dx on [0, 1] = 0.5 — exact for linear functions
397        let result = trapezoid_rule(|x| x, 0.0, 1.0, 1);
398        assert!((result - 0.5).abs() < 1e-12);
399    }
400
401    #[test]
402    fn test_trapezoid_sin_pi() {
403        // ∫ sin(x) dx on [0, π] = 2
404        let result = trapezoid_rule(|x| x.sin(), 0.0, PI, 10_000);
405        assert!((result - 2.0).abs() < 1e-6);
406    }
407
408    #[test]
409    fn test_trapezoid_x_squared() {
410        // ∫ x^2 dx on [0, 1] = 1/3 (trapezoid has O(h^2) error)
411        let result = trapezoid_rule(|x| x * x, 0.0, 1.0, 1_000);
412        assert!((result - 1.0 / 3.0).abs() < 1e-5);
413    }
414
415    #[test]
416    fn test_trapezoid_negative_interval() {
417        // ∫ 1 dx on [-2, -1] = 1
418        let result = trapezoid_rule(|_| 1.0, -2.0, -1.0, 10);
419        assert!((result - 1.0).abs() < 1e-12);
420    }
421
422    // ── simpson ───────────────────────────────────────────────────────────────
423
424    #[test]
425    fn test_simpson_constant() {
426        let result = simpson_rule(|_| 5.0, 0.0, 1.0, 2);
427        assert!((result - 5.0).abs() < 1e-12);
428    }
429
430    #[test]
431    fn test_simpson_x_squared_exact() {
432        // Simpson is exact for degree ≤ 3 polynomials
433        let result = simpson_rule(|x| x * x, 0.0, 1.0, 2);
434        assert!((result - 1.0 / 3.0).abs() < 1e-12);
435    }
436
437    #[test]
438    fn test_simpson_cubic_exact() {
439        // ∫ x^3 dx on [0, 1] = 0.25
440        let result = simpson_rule(|x| x * x * x, 0.0, 1.0, 2);
441        assert!((result - 0.25).abs() < 1e-12);
442    }
443
444    #[test]
445    fn test_simpson_sin_pi() {
446        let result = simpson_rule(|x| x.sin(), 0.0, PI, 100);
447        assert!((result - 2.0).abs() < 1e-5);
448    }
449
450    #[test]
451    fn test_simpson_odd_n_corrected() {
452        // Passing odd n should be auto-corrected to even
453        let result = simpson_rule(|x| x * x, 0.0, 1.0, 3);
454        assert!((result - 1.0 / 3.0).abs() < 1e-6);
455    }
456
457    // ── romberg ───────────────────────────────────────────────────────────────
458
459    #[test]
460    fn test_romberg_constant() {
461        let result = romberg_integration(|_| 2.0, 0.0, 1.0, 5, 1e-10);
462        assert!((result - 2.0).abs() < 1e-12);
463    }
464
465    #[test]
466    fn test_romberg_x_squared() {
467        let result = romberg_integration(|x| x * x, 0.0, 1.0, 6, 1e-12);
468        assert!((result - 1.0 / 3.0).abs() < 1e-10);
469    }
470
471    #[test]
472    fn test_romberg_pi_integral() {
473        // ∫_0^1 4 / (1 + x^2) dx = π
474        let result = romberg_integration(|x| 4.0 / (1.0 + x * x), 0.0, 1.0, 8, 1e-10);
475        assert!((result - PI).abs() < 1e-8);
476    }
477
478    #[test]
479    fn test_romberg_sin_pi() {
480        let result = romberg_integration(|x| x.sin(), 0.0, PI, 6, 1e-10);
481        assert!((result - 2.0).abs() < 1e-8);
482    }
483
484    // ── gauss-legendre 5pt ────────────────────────────────────────────────────
485
486    #[test]
487    fn test_gauss_legendre_5pt_constant() {
488        let result = gauss_legendre_5pt(|_| 1.0, 0.0, 1.0);
489        assert!((result - 1.0).abs() < TOL);
490    }
491
492    #[test]
493    fn test_gauss_legendre_5pt_poly_degree9() {
494        // Should be exact for degree ≤ 9: ∫ x^9 dx on [0,1] = 0.1
495        let result = gauss_legendre_5pt(|x| x.powi(9), 0.0, 1.0);
496        assert!((result - 0.1).abs() < 1e-12);
497    }
498
499    #[test]
500    fn test_gauss_legendre_5pt_sin() {
501        let result = gauss_legendre_5pt(|x| x.sin(), 0.0, PI);
502        assert!((result - 2.0).abs() < 1e-4);
503    }
504
505    // ── gauss_legendre_nodes_weights ──────────────────────────────────────────
506
507    #[test]
508    fn test_gl_nodes_weights_n2_sum_weights() {
509        let (_nodes, weights) = gauss_legendre_nodes_weights(2);
510        let sum: f64 = weights.iter().sum();
511        assert!((sum - 2.0).abs() < 1e-12);
512    }
513
514    #[test]
515    fn test_gl_nodes_weights_n3_sum_weights() {
516        let (_nodes, weights) = gauss_legendre_nodes_weights(3);
517        let sum: f64 = weights.iter().sum();
518        assert!((sum - 2.0).abs() < 1e-12);
519    }
520
521    #[test]
522    fn test_gl_nodes_weights_n4_sum_weights() {
523        let (_nodes, weights) = gauss_legendre_nodes_weights(4);
524        let sum: f64 = weights.iter().sum();
525        assert!((sum - 2.0).abs() < 1e-12);
526    }
527
528    #[test]
529    fn test_gl_nodes_weights_n5_sum_weights() {
530        let (_nodes, weights) = gauss_legendre_nodes_weights(5);
531        let sum: f64 = weights.iter().sum();
532        assert!((sum - 2.0).abs() < 1e-12);
533    }
534
535    #[test]
536    fn test_gl_nodes_exact_for_quadratic() {
537        // 3-point GL is exact for degree ≤ 5. Test ∫_{-1}^{1} x^4 dx = 2/5
538        let (nodes, weights) = gauss_legendre_nodes_weights(3);
539        let result: f64 = nodes
540            .iter()
541            .zip(weights.iter())
542            .map(|(x, w)| w * x.powi(4))
543            .sum();
544        assert!((result - 2.0 / 5.0).abs() < 1e-12);
545    }
546
547    // ── adaptive simpson ──────────────────────────────────────────────────────
548
549    #[test]
550    fn test_adaptive_simpson_sin() {
551        // ∫ sin(x) dx on [0, π] = 2
552        let result = adaptive_simpson(|x| x.sin(), 0.0, PI, 1e-8, 20);
553        assert!((result - 2.0).abs() < 1e-6);
554    }
555
556    #[test]
557    fn test_adaptive_simpson_constant() {
558        let result = adaptive_simpson(|_| 7.0, 0.0, 1.0, 1e-10, 10);
559        assert!((result - 7.0).abs() < 1e-8);
560    }
561
562    #[test]
563    fn test_adaptive_simpson_x_cubed() {
564        // ∫ x^3 dx on [0, 2] = 4
565        let result = adaptive_simpson(|x| x * x * x, 0.0, 2.0, 1e-10, 15);
566        assert!((result - 4.0).abs() < 1e-8);
567    }
568
569    // ── monte carlo ───────────────────────────────────────────────────────────
570
571    #[test]
572    fn test_monte_carlo_constant() {
573        // ∫ 1 dx on [0, 1] = 1 — exact regardless of samples
574        let result = monte_carlo_integrate(|_| 1.0, 0.0, 1.0, 1_000);
575        assert!((result - 1.0).abs() < 1e-12);
576    }
577
578    #[test]
579    fn test_monte_carlo_approx_pi() {
580        // ∫_0^1 4 / (1 + x^2) dx ≈ π, converges in Monte Carlo
581        let result = monte_carlo_integrate(|x| 4.0 / (1.0 + x * x), 0.0, 1.0, 100_000);
582        // Accept 1% relative error for MC
583        assert!((result - PI).abs() < 0.05, "MC result: {result}");
584    }
585
586    #[test]
587    fn test_monte_carlo_negative_interval() {
588        // ∫ 2 dx on [-1, 0] = 2
589        let result = monte_carlo_integrate(|_| 2.0, -1.0, 0.0, 100);
590        assert!((result - 2.0).abs() < 1e-12);
591    }
592
593    // ── clenshaw-curtis ───────────────────────────────────────────────────────
594
595    #[test]
596    fn test_clenshaw_curtis_constant() {
597        // ∫_{-1}^{1} 1 dx = 2
598        let result = clenshaw_curtis(|_| 1.0, 8);
599        assert!((result - 2.0).abs() < 1e-6, "CC constant: {result}");
600    }
601
602    #[test]
603    fn test_clenshaw_curtis_linear_zero() {
604        // ∫_{-1}^{1} x dx = 0 (odd function)
605        let result = clenshaw_curtis(|x| x, 8);
606        assert!(result.abs() < 1e-10, "CC odd: {result}");
607    }
608
609    // ── double exponential ────────────────────────────────────────────────────
610
611    #[test]
612    fn test_double_exponential_constant() {
613        // ∫ 1 dx on [0, 1] = 1
614        let result = double_exponential(|_| 1.0, 0.0, 1.0, 20);
615        assert!((result - 1.0).abs() < 1e-6, "DE constant: {result}");
616    }
617
618    #[test]
619    fn test_double_exponential_sin() {
620        // ∫ sin(x) dx on [0, π] ≈ 2
621        let result = double_exponential(|x| x.sin(), 0.0, PI, 40);
622        assert!((result - 2.0).abs() < 1e-4, "DE sin: {result}");
623    }
624
625    // ── QuadratureRule ────────────────────────────────────────────────────────
626
627    #[test]
628    fn test_quadrature_rule_n_points() {
629        let (nodes, weights) = gauss_legendre_nodes_weights(4);
630        let rule = QuadratureRule::new(nodes, weights, "GL-4");
631        assert_eq!(rule.n_points(), 4);
632    }
633
634    #[test]
635    fn test_quadrature_rule_apply_constant() {
636        let (nodes, weights) = gauss_legendre_nodes_weights(3);
637        let rule = QuadratureRule::new(nodes, weights, "GL-3");
638        let result = rule.apply(|_| 2.0, -1.0, 1.0);
639        assert!((result - 4.0).abs() < 1e-12);
640    }
641
642    #[test]
643    fn test_quadrature_rule_apply_quadratic() {
644        // ∫_0^1 x^2 dx = 1/3; GL-3 is exact for degree ≤ 5
645        let (nodes, weights) = gauss_legendre_nodes_weights(3);
646        let rule = QuadratureRule::new(nodes, weights, "GL-3");
647        let result = rule.apply(|x| x * x, 0.0, 1.0);
648        assert!((result - 1.0 / 3.0).abs() < 1e-12);
649    }
650
651    #[test]
652    fn test_quadrature_rule_name() {
653        let (nodes, weights) = gauss_legendre_nodes_weights(2);
654        let rule = QuadratureRule::new(nodes, weights, "custom");
655        assert_eq!(rule.name, "custom");
656    }
657}