Skip to main content

numra_core/
uncertainty.rs

1//! Uncertainty propagation and sensitivity analysis.
2//!
3//! This module provides tools for:
4//! - Representing uncertain values (mean + variance)
5//! - First-order uncertainty propagation
6//! - Parameter sensitivity analysis
7//!
8//! # Theory
9//!
10//! For a function y = f(x) where x has uncertainty σ_x, the first-order
11//! approximation of the output uncertainty is:
12//!
13//! ```text
14//! σ_y ≈ |∂f/∂x| * σ_x
15//! ```
16//!
17//! For multiple parameters: σ_y² = Σ (∂f/∂xᵢ)² * σ_xᵢ²
18//!
19//! Author: Moussa Leblouba
20//! Date: 8 February 2026
21//! Modified: 2 May 2026
22
23#[cfg(not(feature = "std"))]
24use alloc::string::String;
25#[cfg(not(feature = "std"))]
26use alloc::vec;
27#[cfg(not(feature = "std"))]
28use alloc::vec::Vec;
29
30use crate::Scalar;
31
32/// An uncertain value with mean and variance.
33///
34/// # Example
35///
36/// ```rust
37/// use numra_core::uncertainty::Uncertain;
38///
39/// // A measurement of 10.0 ± 0.5 (std dev)
40/// let x: Uncertain<f64> = Uncertain::new(10.0, 0.25); // variance = 0.5²
41///
42/// // Scale by 2: mean scales, variance scales by 4
43/// let y = x.scale(2.0);
44/// assert!((y.mean - 20.0).abs() < 1e-10);
45/// assert!((y.variance - 1.0).abs() < 1e-10);
46/// ```
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct Uncertain<S: Scalar> {
49    /// Mean value
50    pub mean: S,
51    /// Variance (σ²)
52    pub variance: S,
53}
54
55impl<S: Scalar> Default for Uncertain<S> {
56    fn default() -> Self {
57        Self {
58            mean: S::ZERO,
59            variance: S::ZERO,
60        }
61    }
62}
63
64impl<S: Scalar> Uncertain<S> {
65    /// Create a new uncertain value.
66    pub fn new(mean: S, variance: S) -> Self {
67        Self { mean, variance }
68    }
69
70    /// Create from mean and standard deviation.
71    pub fn from_std(mean: S, std: S) -> Self {
72        Self {
73            mean,
74            variance: std * std,
75        }
76    }
77
78    /// Create a certain value (zero variance).
79    pub fn certain(value: S) -> Self {
80        Self {
81            mean: value,
82            variance: S::ZERO,
83        }
84    }
85
86    /// Standard deviation.
87    pub fn std(&self) -> S {
88        self.variance.sqrt()
89    }
90
91    /// Coefficient of variation (std/mean).
92    pub fn cv(&self) -> S {
93        /// Threshold below which the mean is considered zero for CV computation.
94        const CV_ZERO_THRESHOLD: f64 = 1e-15;
95        if self.mean.abs() < S::from_f64(CV_ZERO_THRESHOLD) {
96            S::INFINITY
97        } else {
98            self.std() / self.mean.abs()
99        }
100    }
101
102    /// Scale by a constant: y = a*x.
103    /// Variance scales by a².
104    pub fn scale(&self, a: S) -> Self {
105        Self {
106            mean: a * self.mean,
107            variance: a * a * self.variance,
108        }
109    }
110
111    /// Add a constant: y = x + c.
112    /// Variance unchanged.
113    pub fn add_const(&self, c: S) -> Self {
114        Self {
115            mean: self.mean + c,
116            variance: self.variance,
117        }
118    }
119
120    /// Add another uncertain value (assuming independence).
121    /// y = x1 + x2, Var(y) = Var(x1) + Var(x2).
122    pub fn add(&self, other: &Self) -> Self {
123        Self {
124            mean: self.mean + other.mean,
125            variance: self.variance + other.variance,
126        }
127    }
128
129    /// Subtract another uncertain value (assuming independence).
130    pub fn sub(&self, other: &Self) -> Self {
131        Self {
132            mean: self.mean - other.mean,
133            variance: self.variance + other.variance,
134        }
135    }
136
137    /// Multiply by another uncertain value (first-order approximation).
138    /// For `y = x1 * x2`:
139    /// `E[y] ≈ E[x1] * E[x2]`
140    /// `Var(y) ≈ E[x2]² * Var(x1) + E[x1]² * Var(x2)`
141    pub fn mul(&self, other: &Self) -> Self {
142        let mean = self.mean * other.mean;
143        let variance =
144            other.mean * other.mean * self.variance + self.mean * self.mean * other.variance;
145        Self { mean, variance }
146    }
147
148    /// Divide by another uncertain value (first-order approximation).
149    /// For `y = x1 / x2`:
150    /// `E[y] ≈ E[x1] / E[x2]`
151    /// `Var(y) ≈ (1/E[x2]²) * Var(x1) + (E[x1]/E[x2]²)² * Var(x2)`
152    pub fn div(&self, other: &Self) -> Self {
153        let mean = self.mean / other.mean;
154        let inv_mean2 = S::ONE / (other.mean * other.mean);
155        let variance = inv_mean2 * self.variance
156            + (self.mean * inv_mean2) * (self.mean * inv_mean2) * other.variance;
157        Self { mean, variance }
158    }
159
160    /// Apply a function with known derivative.
161    /// For y = f(x): Var(y) ≈ (f'(x))² * Var(x).
162    pub fn apply<F, D>(&self, f: F, df: D) -> Self
163    where
164        F: Fn(S) -> S,
165        D: Fn(S) -> S,
166    {
167        let mean = f(self.mean);
168        let deriv = df(self.mean);
169        let variance = deriv * deriv * self.variance;
170        Self { mean, variance }
171    }
172
173    /// Square: y = x².
174    pub fn square(&self) -> Self {
175        self.apply(|x| x * x, |x| S::from_f64(2.0) * x)
176    }
177
178    /// Square root: y = √x.
179    pub fn sqrt_unc(&self) -> Self {
180        self.apply(|x| x.sqrt(), |x| S::ONE / (S::from_f64(2.0) * x.sqrt()))
181    }
182
183    /// Exponential: y = e^x.
184    pub fn exp(&self) -> Self {
185        self.apply(|x| x.exp(), |x| x.exp())
186    }
187
188    /// Natural log: y = ln(x).
189    pub fn ln(&self) -> Self {
190        self.apply(|x| x.ln(), |x| S::ONE / x)
191    }
192
193    /// Sine: y = sin(x).
194    pub fn sin(&self) -> Self {
195        self.apply(|x| x.sin(), |x| x.cos())
196    }
197
198    /// Cosine: y = cos(x).
199    pub fn cos(&self) -> Self {
200        self.apply(|x| x.cos(), |x| -x.sin())
201    }
202}
203
204/// Importance of one parameter for a scalar pure-function output.
205///
206/// Computed by central finite differences inside [`compute_sensitivities`].
207/// Distinct from the ODE forward-sensitivity matrix
208/// `numra_ode::SensitivityResult` (which carries `dy(t)/dp` along a trajectory):
209/// this type is for ranking parameters of a fixed scalar function `f(p) -> S`.
210#[derive(Clone, Debug)]
211pub struct ParameterSensitivity<S: Scalar> {
212    /// Parameter name
213    pub name: String,
214    /// Sensitivity coefficient: ∂y/∂p
215    pub coefficient: S,
216    /// Normalized sensitivity: (p/y) * ∂y/∂p
217    pub normalized: S,
218}
219
220/// Result of parameter-importance analysis on a scalar pure function.
221///
222/// Distinct from the ODE forward-sensitivity result type
223/// `numra_ode::SensitivityResult`. See [`ParameterSensitivity`] for the
224/// distinction.
225#[derive(Clone, Debug)]
226pub struct ParameterSensitivityResult<S: Scalar> {
227    /// Output value at nominal parameters
228    pub output: S,
229    /// Sensitivities for each parameter
230    pub sensitivities: Vec<ParameterSensitivity<S>>,
231}
232
233impl<S: Scalar> ParameterSensitivityResult<S> {
234    /// Create a new sensitivity result.
235    pub fn new(output: S, sensitivities: Vec<ParameterSensitivity<S>>) -> Self {
236        Self {
237            output,
238            sensitivities,
239        }
240    }
241
242    /// Find the most sensitive parameter.
243    pub fn most_sensitive(&self) -> Option<&ParameterSensitivity<S>> {
244        self.sensitivities
245            .iter()
246            .max_by(|a, b| a.normalized.abs().partial_cmp(&b.normalized.abs()).unwrap())
247    }
248
249    /// Propagate uncertainty from parameter uncertainties.
250    /// Returns the output uncertainty (variance).
251    pub fn propagate_uncertainty(&self, param_variances: &[S]) -> S {
252        assert_eq!(param_variances.len(), self.sensitivities.len());
253        let mut total_var = S::ZERO;
254        for (sens, &var) in self.sensitivities.iter().zip(param_variances.iter()) {
255            total_var += sens.coefficient * sens.coefficient * var;
256        }
257        total_var
258    }
259}
260
261/// Compute finite difference sensitivities.
262///
263/// # Arguments
264/// * `f` - Function to evaluate
265/// * `params` - Nominal parameter values
266/// * `names` - Parameter names
267/// * `h` - Step size for finite differences (default: `cbrt(S::EPSILON)`,
268///   the canonical central-FD step factor)
269pub fn compute_sensitivities<S: Scalar, F>(
270    f: F,
271    params: &[S],
272    names: &[&str],
273    h: Option<S>,
274) -> ParameterSensitivityResult<S>
275where
276    F: Fn(&[S]) -> S,
277{
278    let h = h.unwrap_or(S::EPSILON.cbrt());
279    let output = f(params);
280
281    let mut sensitivities = Vec::with_capacity(params.len());
282    let mut params_pert = params.to_vec();
283
284    for (i, name) in names.iter().enumerate() {
285        let p0 = params[i];
286        let step = h * (S::ONE + p0.abs());
287
288        // Central difference
289        params_pert[i] = p0 + step;
290        let f_plus = f(&params_pert);
291        params_pert[i] = p0 - step;
292        let f_minus = f(&params_pert);
293        params_pert[i] = p0;
294
295        let coeff = (f_plus - f_minus) / (S::from_f64(2.0) * step);
296        const NORMALIZED_ZERO_THRESHOLD: f64 = 1e-15;
297        let normalized = if output.abs() > S::from_f64(NORMALIZED_ZERO_THRESHOLD) {
298            (p0 / output) * coeff
299        } else {
300            S::ZERO
301        };
302
303        sensitivities.push(ParameterSensitivity {
304            name: name.to_string(),
305            coefficient: coeff,
306            normalized,
307        });
308    }
309
310    ParameterSensitivityResult::new(output, sensitivities)
311}
312
313/// Interval arithmetic for bounds propagation.
314///
315/// Represents an interval [lower, upper].
316#[derive(Clone, Copy, Debug, PartialEq)]
317pub struct Interval<S: Scalar> {
318    /// Lower bound
319    pub lower: S,
320    /// Upper bound
321    pub upper: S,
322}
323
324impl<S: Scalar> Interval<S> {
325    /// Create a new interval.
326    pub fn new(lower: S, upper: S) -> Self {
327        debug_assert!(lower <= upper, "Invalid interval: lower > upper");
328        Self { lower, upper }
329    }
330
331    /// Create a point interval [x, x].
332    pub fn point(x: S) -> Self {
333        Self { lower: x, upper: x }
334    }
335
336    /// Create from center and half-width.
337    pub fn from_center(center: S, half_width: S) -> Self {
338        Self {
339            lower: center - half_width,
340            upper: center + half_width,
341        }
342    }
343
344    /// Width of the interval.
345    pub fn width(&self) -> S {
346        self.upper - self.lower
347    }
348
349    /// Center of the interval.
350    pub fn center(&self) -> S {
351        (self.lower + self.upper) / S::from_f64(2.0)
352    }
353
354    /// Check if interval contains a value.
355    pub fn contains(&self, x: S) -> bool {
356        x >= self.lower && x <= self.upper
357    }
358
359    /// Add two intervals.
360    pub fn add(&self, other: &Self) -> Self {
361        Self {
362            lower: self.lower + other.lower,
363            upper: self.upper + other.upper,
364        }
365    }
366
367    /// Subtract two intervals.
368    pub fn sub(&self, other: &Self) -> Self {
369        Self {
370            lower: self.lower - other.upper,
371            upper: self.upper - other.lower,
372        }
373    }
374
375    /// Multiply two intervals.
376    pub fn mul(&self, other: &Self) -> Self {
377        let products = [
378            self.lower * other.lower,
379            self.lower * other.upper,
380            self.upper * other.lower,
381            self.upper * other.upper,
382        ];
383        let lower = products.iter().fold(S::INFINITY, |a, &b| a.min(b));
384        let upper = products.iter().fold(S::NEG_INFINITY, |a, &b| a.max(b));
385        Self { lower, upper }
386    }
387
388    /// Scale by a constant.
389    pub fn scale(&self, a: S) -> Self {
390        if a >= S::ZERO {
391            Self {
392                lower: a * self.lower,
393                upper: a * self.upper,
394            }
395        } else {
396            Self {
397                lower: a * self.upper,
398                upper: a * self.lower,
399            }
400        }
401    }
402
403    /// Union of two intervals.
404    pub fn union(&self, other: &Self) -> Self {
405        Self {
406            lower: self.lower.min(other.lower),
407            upper: self.upper.max(other.upper),
408        }
409    }
410
411    /// Intersection of two intervals (None if empty).
412    pub fn intersection(&self, other: &Self) -> Option<Self> {
413        let lower = self.lower.max(other.lower);
414        let upper = self.upper.min(other.upper);
415        if lower <= upper {
416            Some(Self { lower, upper })
417        } else {
418            None
419        }
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_uncertain_basic() {
429        let x: Uncertain<f64> = Uncertain::from_std(10.0, 0.5);
430        assert!((x.mean - 10.0).abs() < 1e-10);
431        assert!((x.variance - 0.25).abs() < 1e-10);
432        assert!((x.std() - 0.5).abs() < 1e-10);
433    }
434
435    #[test]
436    fn test_uncertain_scale() {
437        let x: Uncertain<f64> = Uncertain::from_std(10.0, 1.0);
438        let y = x.scale(2.0);
439        assert!((y.mean - 20.0).abs() < 1e-10);
440        assert!((y.std() - 2.0).abs() < 1e-10);
441    }
442
443    #[test]
444    fn test_uncertain_add() {
445        let x: Uncertain<f64> = Uncertain::new(10.0, 1.0);
446        let y: Uncertain<f64> = Uncertain::new(5.0, 0.5);
447        let z = x.add(&y);
448        assert!((z.mean - 15.0).abs() < 1e-10);
449        assert!((z.variance - 1.5).abs() < 1e-10);
450    }
451
452    #[test]
453    fn test_uncertain_mul() {
454        // x = 10 ± 1, y = 5 ± 0.5
455        // E[xy] ≈ 50
456        // Var(xy) ≈ 25*1 + 100*0.25 = 25 + 25 = 50
457        let x: Uncertain<f64> = Uncertain::new(10.0, 1.0);
458        let y: Uncertain<f64> = Uncertain::new(5.0, 0.25);
459        let z = x.mul(&y);
460        assert!((z.mean - 50.0).abs() < 1e-10);
461        assert!((z.variance - 50.0).abs() < 1e-10);
462    }
463
464    #[test]
465    fn test_uncertain_exp() {
466        // For y = e^x at x=0: dy/dx = e^0 = 1
467        // So Var(y) ≈ 1² * Var(x) = Var(x)
468        let x: Uncertain<f64> = Uncertain::new(0.0, 0.01);
469        let y = x.exp();
470        assert!((y.mean - 1.0).abs() < 1e-10);
471        assert!((y.variance - 0.01).abs() < 1e-10);
472    }
473
474    #[test]
475    fn test_sensitivity_analysis() {
476        // f(a, b) = a * b
477        // ∂f/∂a = b = 2, ∂f/∂b = a = 3
478        let f = |p: &[f64]| p[0] * p[1];
479        let params = [3.0, 2.0];
480        let names = ["a", "b"];
481
482        let result = compute_sensitivities(f, &params, &names, None);
483
484        assert!((result.output - 6.0).abs() < 1e-10);
485        assert!((result.sensitivities[0].coefficient - 2.0).abs() < 1e-5);
486        assert!((result.sensitivities[1].coefficient - 3.0).abs() < 1e-5);
487    }
488
489    #[test]
490    fn test_interval_basic() {
491        let i: Interval<f64> = Interval::new(1.0, 3.0);
492        assert!((i.width() - 2.0).abs() < 1e-10);
493        assert!((i.center() - 2.0).abs() < 1e-10);
494        assert!(i.contains(2.0));
495        assert!(!i.contains(4.0));
496    }
497
498    #[test]
499    fn test_interval_add() {
500        let a: Interval<f64> = Interval::new(1.0, 2.0);
501        let b: Interval<f64> = Interval::new(3.0, 4.0);
502        let c = a.add(&b);
503        assert!((c.lower - 4.0).abs() < 1e-10);
504        assert!((c.upper - 6.0).abs() < 1e-10);
505    }
506
507    #[test]
508    fn test_interval_mul() {
509        let a: Interval<f64> = Interval::new(-1.0, 2.0);
510        let b: Interval<f64> = Interval::new(1.0, 3.0);
511        let c = a.mul(&b);
512        // Products: -1*1=-1, -1*3=-3, 2*1=2, 2*3=6
513        // So c = [-3, 6]
514        assert!((c.lower + 3.0).abs() < 1e-10);
515        assert!((c.upper - 6.0).abs() < 1e-10);
516    }
517
518    #[test]
519    fn test_interval_intersection() {
520        let a: Interval<f64> = Interval::new(1.0, 4.0);
521        let b: Interval<f64> = Interval::new(2.0, 5.0);
522        let c = a.intersection(&b).unwrap();
523        assert!((c.lower - 2.0).abs() < 1e-10);
524        assert!((c.upper - 4.0).abs() < 1e-10);
525
526        let d: Interval<f64> = Interval::new(5.0, 6.0);
527        assert!(a.intersection(&d).is_none());
528    }
529
530    // ============================================================================
531    // Edge Case Tests
532    // ============================================================================
533
534    #[test]
535    fn test_uncertain_zero_variance() {
536        let x: Uncertain<f64> = Uncertain::certain(5.0);
537        assert!(x.variance.abs() < 1e-15);
538        assert!((x.std()).abs() < 1e-15);
539
540        // Operations preserve certainty
541        let y = x.scale(2.0);
542        assert!(y.variance.abs() < 1e-15);
543
544        let z = x.add_const(10.0);
545        assert!(z.variance.abs() < 1e-15);
546    }
547
548    #[test]
549    fn test_uncertain_zero_mean() {
550        let x: Uncertain<f64> = Uncertain::new(0.0, 1.0);
551
552        // CV should be infinity for zero mean
553        assert!(x.cv().is_infinite());
554
555        // Scaling zero still gives zero
556        let y = x.scale(100.0);
557        assert!(y.mean.abs() < 1e-15);
558        assert!((y.variance - 10000.0).abs() < 1e-10);
559    }
560
561    #[test]
562    fn test_uncertain_negative_values() {
563        let x: Uncertain<f64> = Uncertain::from_std(-10.0, 2.0);
564        assert!((x.mean + 10.0).abs() < 1e-10);
565
566        // Scaling by negative flips the mean
567        let y = x.scale(-1.0);
568        assert!((y.mean - 10.0).abs() < 1e-10);
569        assert!((y.variance - x.variance).abs() < 1e-10);
570    }
571
572    #[test]
573    fn test_uncertain_small_values() {
574        let x: Uncertain<f64> = Uncertain::from_std(1e-15, 1e-16);
575        let y: Uncertain<f64> = Uncertain::from_std(1e-15, 1e-16);
576
577        let z = x.add(&y);
578        assert!(z.mean > 0.0);
579        assert!(z.variance > 0.0);
580    }
581
582    #[test]
583    fn test_uncertain_large_values() {
584        let x: Uncertain<f64> = Uncertain::from_std(1e15, 1e14);
585        let y = x.scale(0.5);
586        assert!((y.mean - 5e14).abs() < 1e10);
587    }
588
589    #[test]
590    fn test_interval_point() {
591        let i: Interval<f64> = Interval::point(5.0);
592        assert!((i.width()).abs() < 1e-15);
593        assert!(i.contains(5.0));
594        assert!(!i.contains(5.0 + 1e-10));
595    }
596
597    #[test]
598    fn test_interval_negative() {
599        let i: Interval<f64> = Interval::new(-5.0, -2.0);
600        assert!(i.contains(-3.0));
601        assert!(!i.contains(0.0));
602        assert!((i.center() + 3.5).abs() < 1e-10);
603    }
604
605    #[test]
606    fn test_interval_scale_negative() {
607        let i: Interval<f64> = Interval::new(2.0, 4.0);
608        let j = i.scale(-2.0);
609        // [-2*4, -2*2] = [-8, -4]
610        assert!((j.lower + 8.0).abs() < 1e-10);
611        assert!((j.upper + 4.0).abs() < 1e-10);
612    }
613
614    #[test]
615    fn test_interval_subtract() {
616        let a: Interval<f64> = Interval::new(2.0, 4.0);
617        let b: Interval<f64> = Interval::new(1.0, 3.0);
618        let c = a.sub(&b);
619        // [2-3, 4-1] = [-1, 3]
620        assert!((c.lower + 1.0).abs() < 1e-10);
621        assert!((c.upper - 3.0).abs() < 1e-10);
622    }
623
624    #[test]
625    fn test_interval_union() {
626        let a: Interval<f64> = Interval::new(1.0, 3.0);
627        let b: Interval<f64> = Interval::new(5.0, 7.0);
628        let c = a.union(&b);
629        assert!((c.lower - 1.0).abs() < 1e-10);
630        assert!((c.upper - 7.0).abs() < 1e-10);
631    }
632
633    #[test]
634    fn test_interval_self_intersection() {
635        let a: Interval<f64> = Interval::new(1.0, 5.0);
636        let b = a.intersection(&a).unwrap();
637        assert!((b.lower - a.lower).abs() < 1e-10);
638        assert!((b.upper - a.upper).abs() < 1e-10);
639    }
640
641    #[test]
642    fn test_uncertain_div_small_denominator() {
643        let x: Uncertain<f64> = Uncertain::new(10.0, 1.0);
644        let y: Uncertain<f64> = Uncertain::new(0.01, 0.0001);
645        let z = x.div(&y);
646        // Mean should be large
647        assert!(z.mean > 100.0);
648    }
649
650    #[test]
651    fn test_sensitivity_single_param() {
652        // f(x) = x^2
653        let f = |p: &[f64]| p[0] * p[0];
654        let params = [3.0];
655        let names = ["x"];
656
657        let result = compute_sensitivities(f, &params, &names, None);
658        // ∂f/∂x = 2x = 6
659        assert!((result.sensitivities[0].coefficient - 6.0).abs() < 1e-4);
660    }
661
662    #[test]
663    fn test_sensitivity_zero_output() {
664        // f(x, y) = x - x = 0
665        let f = |p: &[f64]| p[0] - p[0];
666        let params = [5.0];
667        let names = ["x"];
668
669        let result = compute_sensitivities(f, &params, &names, None);
670        assert!(result.output.abs() < 1e-15);
671        // Normalized should be zero because output is zero
672        assert!(result.sensitivities[0].normalized.abs() < 1e-10);
673    }
674}