Skip to main content

scirs2_integrate/
sparse_grid.rs

1//! Sparse grid (Smolyak) quadrature for multi-dimensional integration
2//!
3//! This module implements the Smolyak construction for high-dimensional numerical
4//! integration. Instead of a full tensor product of one-dimensional quadrature rules
5//! (which scales as O(n^d)), sparse grids build a carefully chosen subset of tensor
6//! products that preserves polynomial exactness while requiring only
7//! O(n * (log n)^{d-1}) points.
8//!
9//! ## Key features
10//!
11//! - Smolyak construction with any 1-D nested quadrature rule
12//! - Built-in Clenshaw-Curtis and Gauss-Legendre base rules
13//! - Configurable level for accuracy / cost trade-off
14//! - Exact for total-degree polynomials up to `2*level - 1`
15//!
16//! ## References
17//!
18//! - S.A. Smolyak (1963), "Quadrature and interpolation formulas formed by
19//!   tensor products of one-dimensional operators"
20//! - T. Gerstner, M. Griebel (1998), "Numerical integration using sparse grids"
21
22use crate::error::{IntegrateError, IntegrateResult};
23use crate::IntegrateFloat;
24use scirs2_core::ndarray::Array1;
25use std::collections::HashMap;
26
27/// Helper to convert f64 constants to generic Float type
28#[inline(always)]
29fn to_f<F: IntegrateFloat>(value: f64) -> F {
30    F::from_f64(value).unwrap_or_else(|| F::zero())
31}
32
33// ---------------------------------------------------------------------------
34// 1-D rule abstraction
35// ---------------------------------------------------------------------------
36
37/// A one-dimensional quadrature rule: nodes and weights on `[-1,1]`.
38#[derive(Debug, Clone)]
39pub struct OneDRule {
40    /// Nodes on `[-1,1]`
41    pub nodes: Vec<f64>,
42    /// Corresponding weights
43    pub weights: Vec<f64>,
44}
45
46/// Family of nested 1-D rules indexed by level.
47pub trait OneDRuleFamily: Send + Sync {
48    /// Return the 1-D rule at the given level (level >= 1).
49    /// Level 1 should have 1 point, level 2 should have 3, etc.
50    fn rule(&self, level: usize) -> IntegrateResult<OneDRule>;
51}
52
53/// Clenshaw-Curtis nested rule family (levels 1, 2, 3, ... give 1, 3, 5, ... points).
54#[derive(Debug, Clone, Copy)]
55pub struct ClenshawCurtisFamily;
56
57impl OneDRuleFamily for ClenshawCurtisFamily {
58    fn rule(&self, level: usize) -> IntegrateResult<OneDRule> {
59        if level == 0 {
60            return Err(IntegrateError::ValueError("Level must be >= 1".into()));
61        }
62        // n = 2^(level-1) for level >= 2, n = 1 for level 1
63        let n = if level == 1 { 1 } else { 1 << (level - 1) };
64        cc_rule_f64(n)
65    }
66}
67
68/// Gauss-Legendre rule family (not nested, but usable with Smolyak).
69#[derive(Debug, Clone, Copy)]
70pub struct GaussLegendreFamily;
71
72impl OneDRuleFamily for GaussLegendreFamily {
73    fn rule(&self, level: usize) -> IntegrateResult<OneDRule> {
74        if level == 0 {
75            return Err(IntegrateError::ValueError("Level must be >= 1".into()));
76        }
77        // level k gives a k-point Gauss-Legendre rule
78        gl_rule_f64(level)
79    }
80}
81
82// ---------------------------------------------------------------------------
83// Low-level rule generators (f64)
84// ---------------------------------------------------------------------------
85
86/// Generate Clenshaw-Curtis nodes and weights with `n+1` points on `[-1,1]`.
87fn cc_rule_f64(n: usize) -> IntegrateResult<OneDRule> {
88    if n == 0 {
89        // 1-point midpoint rule
90        return Ok(OneDRule {
91            nodes: vec![0.0],
92            weights: vec![2.0],
93        });
94    }
95    if n == 1 {
96        return Ok(OneDRule {
97            nodes: vec![-1.0, 0.0, 1.0],
98            weights: vec![1.0 / 3.0, 4.0 / 3.0, 1.0 / 3.0],
99        });
100    }
101
102    let nf = n as f64;
103    let pi = std::f64::consts::PI;
104    let mut nodes = Vec::with_capacity(n + 1);
105    let mut weights = Vec::with_capacity(n + 1);
106
107    for j in 0..=n {
108        let theta = j as f64 * pi / nf;
109        nodes.push(theta.cos());
110    }
111
112    let half_n = n / 2;
113    for j in 0..=n {
114        let c_j: f64 = if j == 0 || j == n { 1.0 } else { 2.0 };
115        let theta_j = j as f64 * pi / nf;
116        let mut s = 0.0_f64;
117        for k in 1..=half_n {
118            let b_k: f64 = if k < half_n || (!n.is_multiple_of(2) && k == half_n) {
119                2.0
120            } else {
121                1.0
122            };
123            let denom = (4 * k * k) as f64 - 1.0;
124            s += b_k / denom * (2.0 * k as f64 * theta_j).cos();
125        }
126        weights.push(c_j / nf * (1.0 - s));
127    }
128
129    Ok(OneDRule { nodes, weights })
130}
131
132/// Generate Gauss-Legendre nodes and weights with `n` points on `[-1,1]`.
133fn gl_rule_f64(n: usize) -> IntegrateResult<OneDRule> {
134    if n == 0 {
135        return Err(IntegrateError::ValueError(
136            "Number of GL points must be >= 1".into(),
137        ));
138    }
139    if n == 1 {
140        return Ok(OneDRule {
141            nodes: vec![0.0],
142            weights: vec![2.0],
143        });
144    }
145
146    let pi = std::f64::consts::PI;
147    let mut nodes = vec![0.0_f64; n];
148    let mut weights = vec![0.0_f64; n];
149
150    // Golub-Welsch: eigenvalue approach via Newton iteration on Legendre polynomials
151    let m = n.div_ceil(2);
152    for i in 0..m {
153        // Initial guess
154        let mut z = ((i as f64 + 0.75) / (n as f64 + 0.5) * pi).cos();
155
156        for _ in 0..100 {
157            let mut p0 = 1.0_f64;
158            let mut p1 = z;
159            for k in 2..=n {
160                let kf = k as f64;
161                let p2 = ((2.0 * kf - 1.0) * z * p1 - (kf - 1.0) * p0) / kf;
162                p0 = p1;
163                p1 = p2;
164            }
165            // p1 = P_n(z), derivative = n * (z * P_n - P_{n-1}) / (z^2 - 1)
166            let nf = n as f64;
167            let dp = nf * (z * p1 - p0) / (z * z - 1.0);
168            let delta = p1 / dp;
169            z -= delta;
170            if delta.abs() < 1e-15 {
171                break;
172            }
173        }
174
175        nodes[i] = -z;
176        nodes[n - 1 - i] = z;
177
178        // Weight
179        let mut p0 = 1.0_f64;
180        let mut p1 = z;
181        for k in 2..=n {
182            let kf = k as f64;
183            let p2 = ((2.0 * kf - 1.0) * z * p1 - (kf - 1.0) * p0) / kf;
184            p0 = p1;
185            p1 = p2;
186        }
187        let nf = n as f64;
188        let dp = nf * (z * p1 - p0) / (z * z - 1.0);
189        let w = 2.0 / ((1.0 - z * z) * dp * dp);
190        weights[i] = w;
191        weights[n - 1 - i] = w;
192    }
193
194    Ok(OneDRule { nodes, weights })
195}
196
197// ---------------------------------------------------------------------------
198// Smolyak sparse grid construction
199// ---------------------------------------------------------------------------
200
201/// Result of sparse grid quadrature
202#[derive(Debug, Clone)]
203pub struct SparseGridResult<F: IntegrateFloat> {
204    /// Estimated value of the integral
205    pub value: F,
206    /// Number of grid points used
207    pub n_points: usize,
208    /// Smolyak level
209    pub level: usize,
210    /// Dimensionality
211    pub dim: usize,
212}
213
214/// Options for sparse grid quadrature
215#[derive(Debug, Clone)]
216pub struct SparseGridOptions {
217    /// Smolyak level (higher = more accurate, default 4)
218    pub level: usize,
219    /// Rule family to use
220    pub rule_family: SparseGridRuleFamily,
221}
222
223/// Which 1-D rule family to use
224#[derive(Debug, Clone, Copy, PartialEq)]
225pub enum SparseGridRuleFamily {
226    /// Clenshaw-Curtis (nested, good for smooth functions)
227    ClenshawCurtis,
228    /// Gauss-Legendre (higher polynomial exactness per point)
229    GaussLegendre,
230}
231
232impl Default for SparseGridOptions {
233    fn default() -> Self {
234        Self {
235            level: 4,
236            rule_family: SparseGridRuleFamily::ClenshawCurtis,
237        }
238    }
239}
240
241/// Enumerate all multi-indices alpha in N^d with |alpha|_1 = q and alpha_i >= 1.
242fn enumerate_multi_indices(d: usize, q: usize) -> Vec<Vec<usize>> {
243    if d == 0 {
244        return if q == 0 { vec![vec![]] } else { vec![] };
245    }
246    if d == 1 {
247        return if q >= 1 { vec![vec![q]] } else { vec![] };
248    }
249    let mut result = Vec::new();
250    // alpha_0 ranges from 1 to q - (d-1)  (since remaining d-1 components must be >= 1)
251    let max_first = if q >= d { q - d + 1 } else { return result };
252    for a0 in 1..=max_first {
253        let sub = enumerate_multi_indices(d - 1, q - a0);
254        for mut tail in sub {
255            let mut row = Vec::with_capacity(d);
256            row.push(a0);
257            row.append(&mut tail);
258            result.push(row);
259        }
260    }
261    result
262}
263
264/// Build a sparse grid and compute the integral of `f` over `[a, b]^d`
265/// using the Smolyak algorithm at the specified level.
266///
267/// # Arguments
268///
269/// * `f`       - Integrand accepting an `Array1<F>` of length `d`
270/// * `ranges`  - Integration ranges `(lower, upper)` per dimension
271/// * `options` - Optional sparse grid parameters
272///
273/// # Examples
274///
275/// ```
276/// use scirs2_integrate::sparse_grid::{sparse_grid_quad, SparseGridOptions};
277/// use scirs2_core::ndarray::Array1;
278///
279/// // Integrate f(x,y) = x*y over [0,1]^2 => 0.25
280/// let result = sparse_grid_quad(
281///     |x: &Array1<f64>| x[0] * x[1],
282///     &[(0.0, 1.0), (0.0, 1.0)],
283///     None,
284/// ).expect("sparse_grid_quad");
285/// assert!((result.value - 0.25).abs() < 1e-10);
286/// ```
287pub fn sparse_grid_quad<F, Func>(
288    f: Func,
289    ranges: &[(F, F)],
290    options: Option<SparseGridOptions>,
291) -> IntegrateResult<SparseGridResult<F>>
292where
293    F: IntegrateFloat,
294    Func: Fn(&Array1<F>) -> F,
295{
296    let opts = options.unwrap_or_default();
297    let d = ranges.len();
298    if d == 0 {
299        return Err(IntegrateError::ValueError(
300            "At least one dimension required".into(),
301        ));
302    }
303    let level = opts.level;
304    if level == 0 {
305        return Err(IntegrateError::ValueError("Level must be >= 1".into()));
306    }
307
308    // Build 1-D rules at all needed levels
309    let max_level_1d = level; // max component-level is level (from Smolyak formula)
310    let family: Box<dyn OneDRuleFamily> = match opts.rule_family {
311        SparseGridRuleFamily::ClenshawCurtis => Box::new(ClenshawCurtisFamily),
312        SparseGridRuleFamily::GaussLegendre => Box::new(GaussLegendreFamily),
313    };
314
315    let mut rules_1d = Vec::with_capacity(max_level_1d + 1);
316    rules_1d.push(OneDRule {
317        nodes: vec![],
318        weights: vec![],
319    }); // placeholder for index 0
320    for lv in 1..=max_level_1d {
321        rules_1d.push(family.rule(lv)?);
322    }
323
324    // Smolyak formula:
325    // Q^d_l = sum_{l-d+1 <= |alpha| <= l, alpha_i >= 1}
326    //         (-1)^{l - |alpha|} * C(d-1, l-|alpha|) * (Q^1_{alpha_1} x ... x Q^1_{alpha_d})
327    //
328    // where C(n,k) is the binomial coefficient.
329
330    // We accumulate weighted contributions into a hashmap keyed by point coordinates
331    // (discretised to avoid floating-point deduplication issues).
332    let mut point_weights: HashMap<Vec<i64>, (Array1<F>, F)> = HashMap::new();
333    let mut total_points_used: usize = 0;
334
335    let q_min = if level >= d { level - d + 1 } else { 1 };
336    let q_max = level;
337
338    for q in q_min..=q_max {
339        let multi_indices = enumerate_multi_indices(d, q);
340        let sign: f64 = if (level - q).is_multiple_of(2) {
341            1.0
342        } else {
343            -1.0
344        };
345        let binom = binomial_coeff(d - 1, level - q);
346        let coeff = sign * binom as f64;
347
348        for alpha in &multi_indices {
349            // Build tensor product of 1-D rules at levels alpha[0], ..., alpha[d-1]
350            // Map each 1-D rule from [-1,1] to [a_i, b_i]
351            add_tensor_product(
352                &rules_1d,
353                alpha,
354                ranges,
355                to_f::<F>(coeff),
356                &mut point_weights,
357                &mut total_points_used,
358            )?;
359        }
360    }
361
362    // Sum up
363    let mut integral = F::zero();
364    for (pt, w) in point_weights.values() {
365        integral += *w * f(pt);
366    }
367
368    Ok(SparseGridResult {
369        value: integral,
370        n_points: point_weights.len(),
371        level,
372        dim: d,
373    })
374}
375
376/// Add the weighted tensor product contribution to the point-weight map.
377fn add_tensor_product<F: IntegrateFloat>(
378    rules: &[OneDRule],
379    alpha: &[usize],
380    ranges: &[(F, F)],
381    coeff: F,
382    map: &mut HashMap<Vec<i64>, (Array1<F>, F)>,
383    total: &mut usize,
384) -> IntegrateResult<()> {
385    let d = alpha.len();
386    let half = 0.5_f64;
387
388    // Collect 1-D nodes/weights mapped to physical coordinates
389    let mut dim_nodes: Vec<Vec<f64>> = Vec::with_capacity(d);
390    let mut dim_weights: Vec<Vec<f64>> = Vec::with_capacity(d);
391
392    for (i, &lv) in alpha.iter().enumerate() {
393        let rule = &rules[lv];
394        let (a_f64, b_f64) = (
395            ranges[i]
396                .0
397                .to_f64()
398                .ok_or_else(|| IntegrateError::ComputationError("f64 conversion".into()))?,
399            ranges[i]
400                .1
401                .to_f64()
402                .ok_or_else(|| IntegrateError::ComputationError("f64 conversion".into()))?,
403        );
404        let mid = (a_f64 + b_f64) * half;
405        let half_len = (b_f64 - a_f64) * half;
406
407        let mapped_nodes: Vec<f64> = rule.nodes.iter().map(|&x| mid + half_len * x).collect();
408        let mapped_weights: Vec<f64> = rule.weights.iter().map(|&w| w * half_len).collect();
409        dim_nodes.push(mapped_nodes);
410        dim_weights.push(mapped_weights);
411    }
412
413    // Iterate over tensor product
414    let sizes: Vec<usize> = dim_nodes.iter().map(|v| v.len()).collect();
415    let total_size: usize = sizes.iter().product();
416    *total += total_size;
417
418    let mut indices = vec![0_usize; d];
419    for _ in 0..total_size {
420        // Compute point and weight
421        let mut w_prod = 1.0_f64;
422        let mut point_f64 = vec![0.0_f64; d];
423        for k in 0..d {
424            point_f64[k] = dim_nodes[k][indices[k]];
425            w_prod *= dim_weights[k][indices[k]];
426        }
427
428        // Key for deduplication (round to 14 significant digits)
429        let key: Vec<i64> = point_f64
430            .iter()
431            .map(|&x| (x * 1e14).round() as i64)
432            .collect();
433
434        let w_contrib: F = coeff * F::from_f64(w_prod).unwrap_or_else(|| F::zero());
435
436        let entry = map.entry(key).or_insert_with(|| {
437            let pt_arr = Array1::from_vec(
438                point_f64
439                    .iter()
440                    .map(|&v| F::from_f64(v).unwrap_or_else(|| F::zero()))
441                    .collect(),
442            );
443            (pt_arr, F::zero())
444        });
445        entry.1 += w_contrib;
446
447        // Increment multi-index (lexicographic)
448        let mut carry = true;
449        for k in (0..d).rev() {
450            if carry {
451                indices[k] += 1;
452                if indices[k] >= sizes[k] {
453                    indices[k] = 0;
454                } else {
455                    carry = false;
456                }
457            }
458        }
459    }
460
461    Ok(())
462}
463
464/// Binomial coefficient C(n, k)
465fn binomial_coeff(n: usize, k: usize) -> usize {
466    if k > n {
467        return 0;
468    }
469    if k == 0 || k == n {
470        return 1;
471    }
472    let k = k.min(n - k); // symmetry
473    let mut result: usize = 1;
474    for i in 0..k {
475        result = result * (n - i) / (i + 1);
476    }
477    result
478}
479
480// ---------------------------------------------------------------------------
481// Tests
482// ---------------------------------------------------------------------------
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use scirs2_core::ndarray::Array1;
488
489    #[test]
490    fn test_1d_constant() {
491        // integral of 1 over [0,1] = 1
492        let res = sparse_grid_quad(
493            |_x: &Array1<f64>| 1.0,
494            &[(0.0, 1.0)],
495            Some(SparseGridOptions {
496                level: 2,
497                ..Default::default()
498            }),
499        )
500        .expect("sparse_grid_quad");
501        assert!(
502            (res.value - 1.0).abs() < 1e-14,
503            "1D constant: got {}",
504            res.value
505        );
506    }
507
508    #[test]
509    fn test_2d_polynomial() {
510        // integral of x*y over [0,1]^2 = 0.25
511        let res = sparse_grid_quad(
512            |x: &Array1<f64>| x[0] * x[1],
513            &[(0.0, 1.0), (0.0, 1.0)],
514            Some(SparseGridOptions {
515                level: 3,
516                ..Default::default()
517            }),
518        )
519        .expect("sparse_grid_quad");
520        assert!(
521            (res.value - 0.25).abs() < 1e-10,
522            "2D x*y: got {}",
523            res.value
524        );
525    }
526
527    #[test]
528    fn test_3d_polynomial() {
529        // integral of x*y*z over [0,1]^3 = 0.125
530        let res = sparse_grid_quad(
531            |x: &Array1<f64>| x[0] * x[1] * x[2],
532            &[(0.0, 1.0), (0.0, 1.0), (0.0, 1.0)],
533            Some(SparseGridOptions {
534                level: 3,
535                ..Default::default()
536            }),
537        )
538        .expect("sparse_grid_quad");
539        assert!(
540            (res.value - 0.125).abs() < 1e-10,
541            "3D x*y*z: got {}",
542            res.value
543        );
544    }
545
546    #[test]
547    fn test_2d_gaussian() {
548        // integral of exp(-(x^2+y^2)) over [-3,3]^2 ≈ pi (approx 3.1415...)
549        // Exact: integral of exp(-r^2) from -inf to inf = sqrt(pi), so 2D = pi
550        // But on [-3,3]^2 it is very close to pi.
551        let res = sparse_grid_quad(
552            |x: &Array1<f64>| (-x[0] * x[0] - x[1] * x[1]).exp(),
553            &[(-3.0, 3.0), (-3.0, 3.0)],
554            Some(SparseGridOptions {
555                level: 8,
556                ..Default::default()
557            }),
558        )
559        .expect("sparse_grid_quad");
560        assert!(
561            (res.value - std::f64::consts::PI).abs() < 0.02,
562            "2D Gaussian: got {}, expected pi",
563            res.value
564        );
565    }
566
567    #[test]
568    fn test_gauss_legendre_family() {
569        // Same test with GL family
570        let res = sparse_grid_quad(
571            |x: &Array1<f64>| x[0] * x[1],
572            &[(0.0, 1.0), (0.0, 1.0)],
573            Some(SparseGridOptions {
574                level: 3,
575                rule_family: SparseGridRuleFamily::GaussLegendre,
576            }),
577        )
578        .expect("sparse_grid_quad");
579        assert!(
580            (res.value - 0.25).abs() < 1e-10,
581            "GL 2D x*y: got {}",
582            res.value
583        );
584    }
585
586    #[test]
587    fn test_enumerate_multi_indices() {
588        // d=2, q=3 => alpha in {(1,2), (2,1)}
589        let mi = enumerate_multi_indices(2, 3);
590        assert_eq!(mi.len(), 2);
591    }
592
593    #[test]
594    fn test_binomial() {
595        assert_eq!(binomial_coeff(5, 2), 10);
596        assert_eq!(binomial_coeff(4, 0), 1);
597        assert_eq!(binomial_coeff(4, 4), 1);
598        assert_eq!(binomial_coeff(0, 0), 1);
599        assert_eq!(binomial_coeff(3, 5), 0);
600    }
601
602    #[test]
603    fn test_high_dim_constant() {
604        // integral of 1 over [0,1]^5 = 1
605        // Need level >= d for Smolyak to have at least one term
606        let ranges: Vec<(f64, f64)> = (0..5).map(|_| (0.0, 1.0)).collect();
607        let res = sparse_grid_quad(
608            |_x: &Array1<f64>| 1.0,
609            &ranges,
610            Some(SparseGridOptions {
611                level: 6,
612                ..Default::default()
613            }),
614        )
615        .expect("sparse_grid_quad");
616        assert!(
617            (res.value - 1.0).abs() < 1e-10,
618            "5D constant: got {}",
619            res.value
620        );
621    }
622
623    #[test]
624    fn test_non_unit_domain() {
625        // integral of 1 over [2,5]^2 = 9
626        let res = sparse_grid_quad(
627            |_x: &Array1<f64>| 1.0,
628            &[(2.0, 5.0), (2.0, 5.0)],
629            Some(SparseGridOptions {
630                level: 2,
631                ..Default::default()
632            }),
633        )
634        .expect("sparse_grid_quad");
635        assert!(
636            (res.value - 9.0).abs() < 1e-10,
637            "non-unit domain: got {}",
638            res.value
639        );
640    }
641
642    #[test]
643    fn test_invalid_level() {
644        let res = sparse_grid_quad(
645            |_x: &Array1<f64>| 1.0,
646            &[(0.0, 1.0)],
647            Some(SparseGridOptions {
648                level: 0,
649                ..Default::default()
650            }),
651        );
652        assert!(res.is_err(), "level 0 should be invalid");
653    }
654}