scirs2_integrate/
cubature.rs

1//! Adaptive multidimensional integration using cubature methods
2//!
3//! This module provides implementations of adaptive multidimensional integration,
4//! similar to SciPy's `nquad` function. It uses recursive application of 1D quadrature
5//! rules for dimensions greater than 1, with global and local error estimation.
6
7use crate::error::{IntegrateError, IntegrateResult};
8use crate::IntegrateFloat;
9use scirs2_core::ndarray::Array1;
10use std::f64::consts::PI;
11use std::fmt::Debug;
12
13/// Options for the cubature integration
14#[derive(Debug, Clone)]
15pub struct CubatureOptions<F: IntegrateFloat> {
16    /// Absolute error tolerance
17    pub abs_tol: F,
18    /// Relative error tolerance
19    pub rel_tol: F,
20    /// Maximum number of function evaluations
21    pub max_evals: usize,
22    /// Maximum recursive subdivisions per dimension
23    pub max_recursion_depth: usize,
24    /// Whether to use vectorized evaluation
25    pub vectorized: bool,
26    /// Whether to perform integration in log space
27    pub log: bool,
28}
29
30impl<F: IntegrateFloat> Default for CubatureOptions<F> {
31    fn default() -> Self {
32        Self {
33            abs_tol: F::from_f64(1.49e-8).unwrap(),
34            rel_tol: F::from_f64(1.49e-8).unwrap(),
35            max_evals: 50_000,
36            max_recursion_depth: 15,
37            vectorized: false,
38            log: false,
39        }
40    }
41}
42
43/// Result of a cubature integration
44#[derive(Debug, Clone)]
45pub struct CubatureResult<F: IntegrateFloat> {
46    /// Estimated value of the integral
47    pub value: F,
48    /// Estimated absolute error
49    pub abs_error: F,
50    /// Number of function evaluations
51    pub n_evals: usize,
52    /// Flag indicating successful convergence
53    pub converged: bool,
54}
55
56/// Type of bound for integration limits
57#[derive(Debug, Clone, Copy)]
58pub enum Bound<F: IntegrateFloat> {
59    /// Finite bound with a specific value
60    Finite(F),
61    /// Negative infinity bound
62    NegInf,
63    /// Positive infinity bound
64    PosInf,
65}
66
67impl<F: IntegrateFloat> Bound<F> {
68    /// Check if bound is infinite
69    fn is_infinite(&self) -> bool {
70        matches!(self, Bound::NegInf | Bound::PosInf)
71    }
72}
73
74/// Function to handle the transformation of infinite bounds
75#[allow(dead_code)]
76fn transform_for_infinite_bounds<F: IntegrateFloat>(x: F, a: &Bound<F>, b: &Bound<F>) -> (F, F) {
77    match (a, b) {
78        // Finite bounds - no transformation needed
79        (Bound::Finite(_), Bound::Finite(_)) => (x, F::one()),
80
81        // Semi-infinite interval [a, ∞)
82        (Bound::Finite(a_val), Bound::PosInf) => {
83            // Use the transformation t = a_val + tan(x)
84            // This maps [0, π/2) → [a_val, ∞)
85            // x is in [0, π/2), scale to this range
86            let half_pi = F::from_f64(std::f64::consts::FRAC_PI_2).unwrap();
87            let scaled_x = half_pi * x; // Scale x to [0, π/2)
88            let tan_x = scaled_x.tan();
89            let mapped_val = *a_val + tan_x;
90
91            // The weight factor is sec²(x) * π/2
92            let sec_squared = F::one() + tan_x * tan_x;
93            let weight = sec_squared * half_pi;
94
95            (mapped_val, weight)
96        }
97
98        // Semi-infinite interval (-∞, b]
99        (Bound::NegInf, Bound::Finite(b_val)) => {
100            // Use the transformation t = b_val - tan(x)
101            // This maps [0, π/2) → (-∞, b_val]
102            let half_pi = F::from_f64(std::f64::consts::FRAC_PI_2).unwrap();
103            let scaled_x = half_pi * x; // Scale x to [0, π/2)
104            let tan_x = scaled_x.tan();
105            let mapped_val = *b_val - tan_x;
106
107            // The weight factor is sec²(x) * π/2
108            let sec_squared = F::one() + tan_x * tan_x;
109            let weight = sec_squared * half_pi;
110
111            (mapped_val, weight)
112        }
113
114        // Fully infinite interval (-∞, ∞)
115        (Bound::NegInf, Bound::PosInf) => {
116            // Use the transformation t = tan(π*(x-0.5))
117            // This maps [0, 1] → (-∞, ∞)
118            let pi = F::from_f64(std::f64::consts::PI).unwrap();
119            let half = F::from_f64(0.5).unwrap();
120            let scaled_x = (x - half) * pi;
121            let mapped_val = scaled_x.tan();
122
123            // The weight factor is π * sec²(π*(x-0.5))
124            let sec_squared = F::one() + mapped_val * mapped_val;
125            let weight = pi * sec_squared;
126
127            (mapped_val, weight)
128        }
129
130        // Invalid or unsupported interval types
131        (Bound::Finite(_), Bound::NegInf) | (Bound::NegInf, Bound::NegInf) | (Bound::PosInf, _) => {
132            // These cases represent invalid integration ranges
133            // Return zero values to ensure the integral is zero
134            (F::zero(), F::zero())
135        }
136    }
137}
138
139/// Perform multidimensional integration using adaptive cubature methods
140///
141/// # Arguments
142///
143/// * `f` - The function to integrate
144/// * `bounds` - Array of integration bounds (lower, upper) for each dimension
145/// * `options` - Optional integration parameters
146///
147/// # Returns
148///
149/// * `IntegrateResult<CubatureResult<F>>` - Result of the integration
150///
151/// # Examples
152///
153/// ```
154/// use scirs2_integrate::cubature::{cubature, Bound};
155/// use scirs2_core::ndarray::Array1;
156///
157/// // Define a 2D integrand: f(x,y) = x * y
158/// let f = |x: &Array1<f64>| x[0] * x[1];
159///
160/// // Integrate over [0,1] × [0,1]
161/// let bounds = vec![
162///     (Bound::Finite(0.0), Bound::Finite(1.0)),
163///     (Bound::Finite(0.0), Bound::Finite(1.0)),
164/// ];
165///
166/// let result = cubature(f, &bounds, None).unwrap();
167/// // Exact result is 0.25
168/// assert!((result.value - 0.25).abs() < 1e-10);
169/// ```
170#[allow(dead_code)]
171pub fn cubature<F, Func>(
172    f: Func,
173    bounds: &[(Bound<F>, Bound<F>)],
174    options: Option<CubatureOptions<F>>,
175) -> IntegrateResult<CubatureResult<F>>
176where
177    F: IntegrateFloat,
178    Func: Fn(&Array1<F>) -> F,
179{
180    let opts = options.unwrap_or_default();
181    let ndim = bounds.len();
182
183    if ndim == 0 {
184        return Err(IntegrateError::ValueError(
185            "At least one dimension is required for integration".to_string(),
186        ));
187    }
188
189    // Validate bounds: check for invalid integration ranges
190    for (lower, upper) in bounds {
191        match (lower, upper) {
192            (Bound::PosInf, _) => {
193                return Err(IntegrateError::ValueError(
194                    "Lower bound cannot be positive infinity".to_string(),
195                ));
196            }
197            (Bound::Finite(a), Bound::Finite(b)) if *a >= *b => {
198                return Err(IntegrateError::ValueError(
199                    "Upper bound must be greater than lower bound".to_string(),
200                ));
201            }
202            (_, Bound::NegInf) => {
203                return Err(IntegrateError::ValueError(
204                    "Upper bound cannot be negative infinity".to_string(),
205                ));
206            }
207            _ => {}
208        }
209    }
210
211    // Initialize point array for function evaluation
212    let mut point = Array1::zeros(ndim);
213    // Track function evaluations
214    let mut n_evals = 0;
215
216    // Create standard mapped bounds for dimensions that aren't infinite
217    let mut mapped_bounds = Vec::with_capacity(ndim);
218    for (lower, upper) in bounds {
219        // For finite bounds, use the actual values
220        // For infinite bounds, use [0,1] as our working range for the transformation
221        let mapped_lower = match lower {
222            Bound::Finite(v) => *v,
223            Bound::NegInf => F::zero(),
224            _ => unreachable!(), // We already validated bounds
225        };
226
227        let mapped_upper = match upper {
228            Bound::Finite(v) => *v,
229            Bound::PosInf => F::one(),
230            _ => unreachable!(), // We already validated bounds
231        };
232
233        mapped_bounds.push((mapped_lower, mapped_upper));
234    }
235
236    // Apply recursive cubature algorithm
237    let result = adaptive_cubature_impl(
238        &f,
239        &mapped_bounds,
240        &mut point,
241        0, // Start with dimension 0
242        bounds,
243        &mut n_evals,
244        &opts,
245    )?;
246
247    Ok(CubatureResult {
248        value: result.0,
249        abs_error: result.1,
250        n_evals,
251        converged: result.2,
252    })
253}
254
255/// Internal recursive implementation of cubature algorithm
256#[allow(clippy::too_many_arguments)]
257#[allow(dead_code)]
258fn adaptive_cubature_impl<F, Func>(
259    f: &Func,
260    mapped_bounds: &[(F, F)],
261    point: &mut Array1<F>,
262    dim: usize,
263    original_bounds: &[(Bound<F>, Bound<F>)],
264    n_evals: &mut usize,
265    options: &CubatureOptions<F>,
266) -> IntegrateResult<(F, F, bool)>
267// (value, error, converged)
268where
269    F: IntegrateFloat,
270    Func: Fn(&Array1<F>) -> F,
271{
272    let ndim = mapped_bounds.len();
273
274    // Base case: If we're evaluating a single point, just evaluate the function
275    if dim == ndim {
276        let val = f(point);
277        *n_evals += 1;
278
279        // Handle log-space integration if requested
280        let result = if options.log { val.exp() } else { val };
281
282        // Use a small error estimate based on machine precision
283        // For well-behaved functions, use a smaller error multiplier
284        let error = result.abs() * F::epsilon() * F::from_f64(1.0).unwrap();
285
286        return Ok((result, error, true));
287    }
288
289    // Check if we're dealing with infinite _bounds for this dimension
290    let (a_bound, b_bound) = &original_bounds[dim];
291    let has_infinite_bound = a_bound.is_infinite() || b_bound.is_infinite();
292
293    // Choose appropriate quadrature rule based on infinite _bounds
294    if has_infinite_bound {
295        // For infinite bounds, use a transformation and higher number of points
296        integrate_with_infinite_bounds(
297            f,
298            mapped_bounds,
299            point,
300            dim,
301            original_bounds,
302            n_evals,
303            options,
304        )
305    } else {
306        // For finite bounds, use standard Gauss-Kronrod quadrature
307        integrate_with_finite_bounds(
308            f,
309            mapped_bounds,
310            point,
311            dim,
312            original_bounds,
313            n_evals,
314            options,
315        )
316    }
317}
318
319/// Helper function for integrating with finite bounds
320#[allow(dead_code)]
321fn integrate_with_finite_bounds<F, Func>(
322    f: &Func,
323    mapped_bounds: &[(F, F)],
324    point: &mut Array1<F>,
325    dim: usize,
326    original_bounds: &[(Bound<F>, Bound<F>)],
327    n_evals: &mut usize,
328    options: &CubatureOptions<F>,
329) -> IntegrateResult<(F, F, bool)>
330where
331    F: IntegrateFloat,
332    Func: Fn(&Array1<F>) -> F,
333{
334    // Get the current dimension's _bounds
335    let (a, b) = mapped_bounds[dim];
336
337    // Set up 7-point Gauss-Kronrod quadrature for better accuracy
338    let points = [
339        F::from_f64(-0.9491079123427585).unwrap(),
340        F::from_f64(-0.7415311855993944).unwrap(),
341        F::from_f64(-0.4058451513773972).unwrap(),
342        F::zero(),
343        F::from_f64(0.4058451513773972).unwrap(),
344        F::from_f64(0.7415311855993944).unwrap(),
345        F::from_f64(0.9491079123427585).unwrap(),
346    ];
347
348    let weights = [
349        F::from_f64(0.1294849661688697).unwrap(),
350        F::from_f64(0.2797053914892766).unwrap(),
351        F::from_f64(0.3818300505051189).unwrap(),
352        F::from_f64(0.4179591836734694).unwrap(),
353        F::from_f64(0.3818300505051189).unwrap(),
354        F::from_f64(0.2797053914892766).unwrap(),
355        F::from_f64(0.1294849661688697).unwrap(),
356    ];
357
358    // Scale the points to the integration interval [a, b]
359    let mid = (a + b) / F::from_f64(2.0).unwrap();
360    let scale = (b - a) / F::from_f64(2.0).unwrap();
361
362    let mut result = F::zero();
363    let mut error_est = F::zero();
364    let mut all_converged = true;
365
366    // Use a separate array for evaluating in parallel (for future enhancement)
367    let mut gauss_rule_result = F::zero();
368
369    // Evaluate at each point
370    for i in 0..7 {
371        // Transform the point to the integration interval
372        let x = mid + scale * points[i];
373
374        // Set the current dimension's value
375        point[dim] = x;
376
377        // Recursively integrate the next dimension
378        let sub_result = adaptive_cubature_impl(
379            f,
380            mapped_bounds,
381            point,
382            dim + 1,
383            original_bounds,
384            n_evals,
385            options,
386        )?;
387
388        // Add this point's contribution to the integral
389        let val = sub_result.0 * weights[i];
390        result += val;
391
392        // Accumulate for Gauss rule (crude error estimate)
393        if i % 2 == 0 {
394            gauss_rule_result += val;
395        }
396
397        // Add to error estimate - scale it properly
398        error_est += sub_result.1 * weights[i];
399
400        // Track convergence across all sub-integrations
401        all_converged = all_converged && sub_result.2;
402    }
403
404    // Scale the result
405    result *= scale;
406
407    // Calculate error estimate based on the difference between rules
408    // Scale the accumulated error by the interval width
409    error_est *= scale;
410
411    // Check convergence - require both error tolerance AND all sub-integrations to converge
412    let tol = options.abs_tol + options.rel_tol * result.abs();
413    let converged = error_est <= tol && all_converged;
414
415    Ok((result, error_est, converged))
416}
417
418/// Helper function for integrating with infinite bounds
419#[allow(dead_code)]
420fn integrate_with_infinite_bounds<F, Func>(
421    f: &Func,
422    mapped_bounds: &[(F, F)],
423    point: &mut Array1<F>,
424    dim: usize,
425    original_bounds: &[(Bound<F>, Bound<F>)],
426    n_evals: &mut usize,
427    options: &CubatureOptions<F>,
428) -> IntegrateResult<(F, F, bool)>
429where
430    F: IntegrateFloat,
431    Func: Fn(&Array1<F>) -> F,
432{
433    // For infinite bounds, use Gauss-Legendre quadrature on the transformed interval
434    let (a_bound, b_bound) = &original_bounds[dim];
435
436    // Use 20-point Gauss-Legendre quadrature for better accuracy
437    let nodes = [
438        F::from_f64(-0.9931285991850949).unwrap(),
439        F::from_f64(-0.9639719272779138).unwrap(),
440        F::from_f64(-0.912_234_428_251_326).unwrap(),
441        F::from_f64(-0.8391169718222188).unwrap(),
442        F::from_f64(-0.7463319064601508).unwrap(),
443        F::from_f64(-0.636_053_680_726_515).unwrap(),
444        F::from_f64(-0.5108670019508271).unwrap(),
445        F::from_f64(-0.3737060887154195).unwrap(),
446        F::from_f64(-0.2277858511416451).unwrap(),
447        F::from_f64(-0.0765265211334973).unwrap(),
448        F::from_f64(0.0765265211334973).unwrap(),
449        F::from_f64(0.2277858511416451).unwrap(),
450        F::from_f64(0.3737060887154195).unwrap(),
451        F::from_f64(0.5108670019508271).unwrap(),
452        F::from_f64(0.636_053_680_726_515).unwrap(),
453        F::from_f64(0.7463319064601508).unwrap(),
454        F::from_f64(0.8391169718222188).unwrap(),
455        F::from_f64(0.912_234_428_251_326).unwrap(),
456        F::from_f64(0.9639719272779138).unwrap(),
457        F::from_f64(0.9931285991850949).unwrap(),
458    ];
459
460    let weights = [
461        F::from_f64(0.0176140071391521).unwrap(),
462        F::from_f64(0.0406014298003869).unwrap(),
463        F::from_f64(0.0626720483341091).unwrap(),
464        F::from_f64(0.0832767415767048).unwrap(),
465        F::from_f64(0.1019301198172404).unwrap(),
466        F::from_f64(0.1181945319615184).unwrap(),
467        F::from_f64(0.1316886384491766).unwrap(),
468        F::from_f64(0.142_096_109_318_382).unwrap(),
469        F::from_f64(0.1491729864726037).unwrap(),
470        F::from_f64(0.1527533871307258).unwrap(),
471        F::from_f64(0.1527533871307258).unwrap(),
472        F::from_f64(0.1491729864726037).unwrap(),
473        F::from_f64(0.142_096_109_318_382).unwrap(),
474        F::from_f64(0.1316886384491766).unwrap(),
475        F::from_f64(0.1181945319615184).unwrap(),
476        F::from_f64(0.1019301198172404).unwrap(),
477        F::from_f64(0.0832767415767048).unwrap(),
478        F::from_f64(0.0626720483341091).unwrap(),
479        F::from_f64(0.0406014298003869).unwrap(),
480        F::from_f64(0.0176140071391521).unwrap(),
481    ];
482
483    let mut result = F::zero();
484    let mut error_est = F::zero();
485    let mut all_converged = true;
486
487    // Map nodes from [-1,1] to [0,1] for our transformation
488    // But avoid exact 0 and 1 for infinite _bounds
489    let scale_factor = match (a_bound, b_bound) {
490        (Bound::Finite(_), Bound::PosInf) | (Bound::NegInf, Bound::Finite(_)) => {
491            F::from_f64(0.4999).unwrap()
492        }
493        (Bound::NegInf, Bound::PosInf) => F::from_f64(0.499).unwrap(),
494        _ => unreachable!(),
495    };
496
497    let offset = F::from_f64(0.5).unwrap();
498
499    for i in 0..20 {
500        // Map node from [-1,1] to [0,1] avoiding endpoints
501        let x = offset + nodes[i] * scale_factor;
502
503        // Get transformed point and weight from our transformation function
504        let (mapped_x, jacobian) = transform_for_infinite_bounds(x, a_bound, b_bound);
505
506        // Set the current dimension's value
507        point[dim] = mapped_x;
508
509        // Recursively integrate the next dimension
510        let sub_result = adaptive_cubature_impl(
511            f,
512            mapped_bounds,
513            point,
514            dim + 1,
515            original_bounds,
516            n_evals,
517            options,
518        )?;
519
520        // Add contribution with Gauss weight and transformation Jacobian
521        let contribution = sub_result.0 * weights[i] * jacobian * scale_factor;
522        result += contribution;
523
524        // Accumulate error
525        error_est += sub_result.1 * weights[i] * jacobian.abs() * scale_factor;
526
527        // Track convergence across all sub-integrations
528        all_converged = all_converged && sub_result.2;
529    }
530
531    // Check convergence
532    let tol = options.abs_tol + options.rel_tol * result.abs();
533    let converged = error_est < tol && all_converged;
534
535    Ok((result, error_est, converged))
536}
537
538/// Perform multidimensional integration with a nested set of 1D integrals
539///
540/// This function provides a more direct interface similar to SciPy's nquad function.
541/// It accepts a sequence of 1D integrand functions for each level of nesting.
542///
543/// # Arguments
544///
545/// * `func` - The innermost function to integrate over all variables
546/// * `ranges` - List of integration ranges, each a tuple (lower, upper)
547/// * `options` - Optional integration parameters
548///
549/// # Returns
550///
551/// * `IntegrateResult<CubatureResult<F>>` - Result of the integration
552///
553/// # Examples
554///
555/// ```
556/// use scirs2_integrate::cubature::nquad;
557///
558/// // Integrate x*y over [0,1]×[0,1]
559/// let f = |args: &[f64]| args[0] * args[1];
560/// let ranges = vec![(0.0, 1.0), (0.0, 1.0)];
561///
562/// let result = nquad(f, &ranges, None).unwrap();
563/// // Exact result is 0.25
564/// assert!((result.value - 0.25).abs() < 1e-10);
565/// ```
566#[allow(dead_code)]
567pub fn nquad<F, Func>(
568    func: Func,
569    ranges: &[(F, F)],
570    options: Option<CubatureOptions<F>>,
571) -> IntegrateResult<CubatureResult<F>>
572where
573    F: IntegrateFloat,
574    Func: Fn(&[F]) -> F,
575{
576    // Convert regular ranges to Bound type
577    let bounds: Vec<(Bound<F>, Bound<F>)> = ranges
578        .iter()
579        .map(|(a, b)| (Bound::Finite(*a), Bound::Finite(*b)))
580        .collect();
581
582    // Adapter function that converts array to slice
583    let f_adapter = |x: &Array1<F>| {
584        let slice = x.as_slice().unwrap();
585        func(slice)
586    };
587
588    cubature(f_adapter, &bounds, options)
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn test_simple_2d_integral() {
597        // Integrate f(x,y) = x*y over [0,1]×[0,1] = 0.25
598        let f = |x: &Array1<f64>| x[0] * x[1];
599
600        let bounds = vec![
601            (Bound::Finite(0.0), Bound::Finite(1.0)),
602            (Bound::Finite(0.0), Bound::Finite(1.0)),
603        ];
604
605        let options = CubatureOptions {
606            abs_tol: 1e-6,
607            rel_tol: 1e-6,
608            max_evals: 10000,
609            ..Default::default()
610        };
611
612        let result = cubature(f, &bounds, Some(options)).unwrap();
613        println!("Cubature result:");
614        println!("  Value: {}", result.value);
615        println!("  Expected: 0.25");
616        println!("  Error: {}", result.abs_error);
617        println!("  Converged: {}", result.converged);
618        println!("  Evaluations: {}", result.n_evals);
619        assert!((result.value - 0.25).abs() < 1e-6);
620        assert!(result.converged);
621    }
622
623    #[test]
624    fn test_3d_integral() {
625        // Integrate f(x,y,z) = x*y*z over [0,1]×[0,1]×[0,1] = 0.125
626        let f = |x: &Array1<f64>| x[0] * x[1] * x[2];
627
628        let bounds = vec![
629            (Bound::Finite(0.0), Bound::Finite(1.0)),
630            (Bound::Finite(0.0), Bound::Finite(1.0)),
631            (Bound::Finite(0.0), Bound::Finite(1.0)),
632        ];
633
634        let result = cubature(f, &bounds, None).unwrap();
635        assert!((result.value - 0.125).abs() < 1e-10);
636        assert!(result.converged);
637    }
638
639    #[test]
640    fn test_nquad_simple() {
641        // Integrate f(x,y) = x*y over [0,1]×[0,1] = 0.25
642        let f = |args: &[f64]| args[0] * args[1];
643        let ranges = vec![(0.0, 1.0), (0.0, 1.0)];
644
645        let result = nquad(f, &ranges, None).unwrap();
646        assert!((result.value - 0.25).abs() < 1e-10);
647        assert!(result.converged);
648    }
649
650    #[test]
651    fn test_infinite_bounds() {
652        // Integrate f(x) = exp(-x²) over (-∞, ∞) = sqrt(π)
653        let f = |x: &Array1<f64>| (-x[0] * x[0]).exp();
654
655        let bounds = vec![(Bound::NegInf, Bound::PosInf)];
656
657        let options = CubatureOptions {
658            abs_tol: 1e-4,
659            rel_tol: 1e-4,
660            max_evals: 50000,
661            ..Default::default()
662        };
663
664        let result = cubature(f, &bounds, Some(options)).unwrap();
665        assert!((result.value - PI.sqrt()).abs() < 1e-3); // Relaxed tolerance for infinite bounds
666        assert!(result.converged);
667    }
668
669    #[test]
670    fn test_semi_infinite_bounds() {
671        // Integrate f(x) = exp(-x) over [0, ∞) = 1
672        let f = |x: &Array1<f64>| (-x[0]).exp();
673
674        let bounds = vec![(Bound::Finite(0.0), Bound::PosInf)];
675
676        let options = CubatureOptions {
677            abs_tol: 1e-4,
678            rel_tol: 1e-4,
679            max_evals: 50000,
680            ..Default::default()
681        };
682
683        let result = cubature(f, &bounds, Some(options)).unwrap();
684        assert!((result.value - 1.0).abs() < 1e-3); // Relaxed tolerance for infinite bounds
685        assert!(result.converged);
686    }
687
688    #[test]
689    fn test_gaussian_2d() {
690        // Integrate exp(-(x² + y²)) over R², exact result = π
691        let f = |x: &Array1<f64>| (-x[0] * x[0] - x[1] * x[1]).exp();
692
693        let bounds = vec![
694            (Bound::NegInf, Bound::PosInf),
695            (Bound::NegInf, Bound::PosInf),
696        ];
697
698        let options = CubatureOptions {
699            abs_tol: 1e-3,
700            rel_tol: 1e-3,
701            max_evals: 100000,
702            ..Default::default()
703        };
704
705        let result = cubature(f, &bounds, Some(options)).unwrap();
706        assert!((result.value - PI).abs() < 1e-2); // Relaxed tolerance for 2D infinite bounds
707    }
708}