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: 1e-7)
268pub fn compute_sensitivities<S: Scalar, F>(
269    f: F,
270    params: &[S],
271    names: &[&str],
272    h: Option<S>,
273) -> ParameterSensitivityResult<S>
274where
275    F: Fn(&[S]) -> S,
276{
277    /// Default step size for central-difference sensitivity computation.
278    const DEFAULT_SENSITIVITY_EPS: f64 = 1e-7;
279    let h = h.unwrap_or(S::from_f64(DEFAULT_SENSITIVITY_EPS));
280    let output = f(params);
281
282    let mut sensitivities = Vec::with_capacity(params.len());
283    let mut params_pert = params.to_vec();
284
285    for (i, name) in names.iter().enumerate() {
286        let p0 = params[i];
287        let step = h * (S::ONE + p0.abs());
288
289        // Central difference
290        params_pert[i] = p0 + step;
291        let f_plus = f(&params_pert);
292        params_pert[i] = p0 - step;
293        let f_minus = f(&params_pert);
294        params_pert[i] = p0;
295
296        let coeff = (f_plus - f_minus) / (S::from_f64(2.0) * step);
297        const NORMALIZED_ZERO_THRESHOLD: f64 = 1e-15;
298        let normalized = if output.abs() > S::from_f64(NORMALIZED_ZERO_THRESHOLD) {
299            (p0 / output) * coeff
300        } else {
301            S::ZERO
302        };
303
304        sensitivities.push(ParameterSensitivity {
305            name: name.to_string(),
306            coefficient: coeff,
307            normalized,
308        });
309    }
310
311    ParameterSensitivityResult::new(output, sensitivities)
312}
313
314/// Interval arithmetic for bounds propagation.
315///
316/// Represents an interval [lower, upper].
317#[derive(Clone, Copy, Debug, PartialEq)]
318pub struct Interval<S: Scalar> {
319    /// Lower bound
320    pub lower: S,
321    /// Upper bound
322    pub upper: S,
323}
324
325impl<S: Scalar> Interval<S> {
326    /// Create a new interval.
327    pub fn new(lower: S, upper: S) -> Self {
328        debug_assert!(lower <= upper, "Invalid interval: lower > upper");
329        Self { lower, upper }
330    }
331
332    /// Create a point interval [x, x].
333    pub fn point(x: S) -> Self {
334        Self { lower: x, upper: x }
335    }
336
337    /// Create from center and half-width.
338    pub fn from_center(center: S, half_width: S) -> Self {
339        Self {
340            lower: center - half_width,
341            upper: center + half_width,
342        }
343    }
344
345    /// Width of the interval.
346    pub fn width(&self) -> S {
347        self.upper - self.lower
348    }
349
350    /// Center of the interval.
351    pub fn center(&self) -> S {
352        (self.lower + self.upper) / S::from_f64(2.0)
353    }
354
355    /// Check if interval contains a value.
356    pub fn contains(&self, x: S) -> bool {
357        x >= self.lower && x <= self.upper
358    }
359
360    /// Add two intervals.
361    pub fn add(&self, other: &Self) -> Self {
362        Self {
363            lower: self.lower + other.lower,
364            upper: self.upper + other.upper,
365        }
366    }
367
368    /// Subtract two intervals.
369    pub fn sub(&self, other: &Self) -> Self {
370        Self {
371            lower: self.lower - other.upper,
372            upper: self.upper - other.lower,
373        }
374    }
375
376    /// Multiply two intervals.
377    pub fn mul(&self, other: &Self) -> Self {
378        let products = [
379            self.lower * other.lower,
380            self.lower * other.upper,
381            self.upper * other.lower,
382            self.upper * other.upper,
383        ];
384        let lower = products.iter().fold(S::INFINITY, |a, &b| a.min(b));
385        let upper = products.iter().fold(S::NEG_INFINITY, |a, &b| a.max(b));
386        Self { lower, upper }
387    }
388
389    /// Scale by a constant.
390    pub fn scale(&self, a: S) -> Self {
391        if a >= S::ZERO {
392            Self {
393                lower: a * self.lower,
394                upper: a * self.upper,
395            }
396        } else {
397            Self {
398                lower: a * self.upper,
399                upper: a * self.lower,
400            }
401        }
402    }
403
404    /// Union of two intervals.
405    pub fn union(&self, other: &Self) -> Self {
406        Self {
407            lower: self.lower.min(other.lower),
408            upper: self.upper.max(other.upper),
409        }
410    }
411
412    /// Intersection of two intervals (None if empty).
413    pub fn intersection(&self, other: &Self) -> Option<Self> {
414        let lower = self.lower.max(other.lower);
415        let upper = self.upper.min(other.upper);
416        if lower <= upper {
417            Some(Self { lower, upper })
418        } else {
419            None
420        }
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn test_uncertain_basic() {
430        let x: Uncertain<f64> = Uncertain::from_std(10.0, 0.5);
431        assert!((x.mean - 10.0).abs() < 1e-10);
432        assert!((x.variance - 0.25).abs() < 1e-10);
433        assert!((x.std() - 0.5).abs() < 1e-10);
434    }
435
436    #[test]
437    fn test_uncertain_scale() {
438        let x: Uncertain<f64> = Uncertain::from_std(10.0, 1.0);
439        let y = x.scale(2.0);
440        assert!((y.mean - 20.0).abs() < 1e-10);
441        assert!((y.std() - 2.0).abs() < 1e-10);
442    }
443
444    #[test]
445    fn test_uncertain_add() {
446        let x: Uncertain<f64> = Uncertain::new(10.0, 1.0);
447        let y: Uncertain<f64> = Uncertain::new(5.0, 0.5);
448        let z = x.add(&y);
449        assert!((z.mean - 15.0).abs() < 1e-10);
450        assert!((z.variance - 1.5).abs() < 1e-10);
451    }
452
453    #[test]
454    fn test_uncertain_mul() {
455        // x = 10 ± 1, y = 5 ± 0.5
456        // E[xy] ≈ 50
457        // Var(xy) ≈ 25*1 + 100*0.25 = 25 + 25 = 50
458        let x: Uncertain<f64> = Uncertain::new(10.0, 1.0);
459        let y: Uncertain<f64> = Uncertain::new(5.0, 0.25);
460        let z = x.mul(&y);
461        assert!((z.mean - 50.0).abs() < 1e-10);
462        assert!((z.variance - 50.0).abs() < 1e-10);
463    }
464
465    #[test]
466    fn test_uncertain_exp() {
467        // For y = e^x at x=0: dy/dx = e^0 = 1
468        // So Var(y) ≈ 1² * Var(x) = Var(x)
469        let x: Uncertain<f64> = Uncertain::new(0.0, 0.01);
470        let y = x.exp();
471        assert!((y.mean - 1.0).abs() < 1e-10);
472        assert!((y.variance - 0.01).abs() < 1e-10);
473    }
474
475    #[test]
476    fn test_sensitivity_analysis() {
477        // f(a, b) = a * b
478        // ∂f/∂a = b = 2, ∂f/∂b = a = 3
479        let f = |p: &[f64]| p[0] * p[1];
480        let params = [3.0, 2.0];
481        let names = ["a", "b"];
482
483        let result = compute_sensitivities(f, &params, &names, None);
484
485        assert!((result.output - 6.0).abs() < 1e-10);
486        assert!((result.sensitivities[0].coefficient - 2.0).abs() < 1e-5);
487        assert!((result.sensitivities[1].coefficient - 3.0).abs() < 1e-5);
488    }
489
490    #[test]
491    fn test_interval_basic() {
492        let i: Interval<f64> = Interval::new(1.0, 3.0);
493        assert!((i.width() - 2.0).abs() < 1e-10);
494        assert!((i.center() - 2.0).abs() < 1e-10);
495        assert!(i.contains(2.0));
496        assert!(!i.contains(4.0));
497    }
498
499    #[test]
500    fn test_interval_add() {
501        let a: Interval<f64> = Interval::new(1.0, 2.0);
502        let b: Interval<f64> = Interval::new(3.0, 4.0);
503        let c = a.add(&b);
504        assert!((c.lower - 4.0).abs() < 1e-10);
505        assert!((c.upper - 6.0).abs() < 1e-10);
506    }
507
508    #[test]
509    fn test_interval_mul() {
510        let a: Interval<f64> = Interval::new(-1.0, 2.0);
511        let b: Interval<f64> = Interval::new(1.0, 3.0);
512        let c = a.mul(&b);
513        // Products: -1*1=-1, -1*3=-3, 2*1=2, 2*3=6
514        // So c = [-3, 6]
515        assert!((c.lower + 3.0).abs() < 1e-10);
516        assert!((c.upper - 6.0).abs() < 1e-10);
517    }
518
519    #[test]
520    fn test_interval_intersection() {
521        let a: Interval<f64> = Interval::new(1.0, 4.0);
522        let b: Interval<f64> = Interval::new(2.0, 5.0);
523        let c = a.intersection(&b).unwrap();
524        assert!((c.lower - 2.0).abs() < 1e-10);
525        assert!((c.upper - 4.0).abs() < 1e-10);
526
527        let d: Interval<f64> = Interval::new(5.0, 6.0);
528        assert!(a.intersection(&d).is_none());
529    }
530
531    // ============================================================================
532    // Edge Case Tests
533    // ============================================================================
534
535    #[test]
536    fn test_uncertain_zero_variance() {
537        let x: Uncertain<f64> = Uncertain::certain(5.0);
538        assert!(x.variance.abs() < 1e-15);
539        assert!((x.std()).abs() < 1e-15);
540
541        // Operations preserve certainty
542        let y = x.scale(2.0);
543        assert!(y.variance.abs() < 1e-15);
544
545        let z = x.add_const(10.0);
546        assert!(z.variance.abs() < 1e-15);
547    }
548
549    #[test]
550    fn test_uncertain_zero_mean() {
551        let x: Uncertain<f64> = Uncertain::new(0.0, 1.0);
552
553        // CV should be infinity for zero mean
554        assert!(x.cv().is_infinite());
555
556        // Scaling zero still gives zero
557        let y = x.scale(100.0);
558        assert!(y.mean.abs() < 1e-15);
559        assert!((y.variance - 10000.0).abs() < 1e-10);
560    }
561
562    #[test]
563    fn test_uncertain_negative_values() {
564        let x: Uncertain<f64> = Uncertain::from_std(-10.0, 2.0);
565        assert!((x.mean + 10.0).abs() < 1e-10);
566
567        // Scaling by negative flips the mean
568        let y = x.scale(-1.0);
569        assert!((y.mean - 10.0).abs() < 1e-10);
570        assert!((y.variance - x.variance).abs() < 1e-10);
571    }
572
573    #[test]
574    fn test_uncertain_small_values() {
575        let x: Uncertain<f64> = Uncertain::from_std(1e-15, 1e-16);
576        let y: Uncertain<f64> = Uncertain::from_std(1e-15, 1e-16);
577
578        let z = x.add(&y);
579        assert!(z.mean > 0.0);
580        assert!(z.variance > 0.0);
581    }
582
583    #[test]
584    fn test_uncertain_large_values() {
585        let x: Uncertain<f64> = Uncertain::from_std(1e15, 1e14);
586        let y = x.scale(0.5);
587        assert!((y.mean - 5e14).abs() < 1e10);
588    }
589
590    #[test]
591    fn test_interval_point() {
592        let i: Interval<f64> = Interval::point(5.0);
593        assert!((i.width()).abs() < 1e-15);
594        assert!(i.contains(5.0));
595        assert!(!i.contains(5.0 + 1e-10));
596    }
597
598    #[test]
599    fn test_interval_negative() {
600        let i: Interval<f64> = Interval::new(-5.0, -2.0);
601        assert!(i.contains(-3.0));
602        assert!(!i.contains(0.0));
603        assert!((i.center() + 3.5).abs() < 1e-10);
604    }
605
606    #[test]
607    fn test_interval_scale_negative() {
608        let i: Interval<f64> = Interval::new(2.0, 4.0);
609        let j = i.scale(-2.0);
610        // [-2*4, -2*2] = [-8, -4]
611        assert!((j.lower + 8.0).abs() < 1e-10);
612        assert!((j.upper + 4.0).abs() < 1e-10);
613    }
614
615    #[test]
616    fn test_interval_subtract() {
617        let a: Interval<f64> = Interval::new(2.0, 4.0);
618        let b: Interval<f64> = Interval::new(1.0, 3.0);
619        let c = a.sub(&b);
620        // [2-3, 4-1] = [-1, 3]
621        assert!((c.lower + 1.0).abs() < 1e-10);
622        assert!((c.upper - 3.0).abs() < 1e-10);
623    }
624
625    #[test]
626    fn test_interval_union() {
627        let a: Interval<f64> = Interval::new(1.0, 3.0);
628        let b: Interval<f64> = Interval::new(5.0, 7.0);
629        let c = a.union(&b);
630        assert!((c.lower - 1.0).abs() < 1e-10);
631        assert!((c.upper - 7.0).abs() < 1e-10);
632    }
633
634    #[test]
635    fn test_interval_self_intersection() {
636        let a: Interval<f64> = Interval::new(1.0, 5.0);
637        let b = a.intersection(&a).unwrap();
638        assert!((b.lower - a.lower).abs() < 1e-10);
639        assert!((b.upper - a.upper).abs() < 1e-10);
640    }
641
642    #[test]
643    fn test_uncertain_div_small_denominator() {
644        let x: Uncertain<f64> = Uncertain::new(10.0, 1.0);
645        let y: Uncertain<f64> = Uncertain::new(0.01, 0.0001);
646        let z = x.div(&y);
647        // Mean should be large
648        assert!(z.mean > 100.0);
649    }
650
651    #[test]
652    fn test_sensitivity_single_param() {
653        // f(x) = x^2
654        let f = |p: &[f64]| p[0] * p[0];
655        let params = [3.0];
656        let names = ["x"];
657
658        let result = compute_sensitivities(f, &params, &names, None);
659        // ∂f/∂x = 2x = 6
660        assert!((result.sensitivities[0].coefficient - 6.0).abs() < 1e-4);
661    }
662
663    #[test]
664    fn test_sensitivity_zero_output() {
665        // f(x, y) = x - x = 0
666        let f = |p: &[f64]| p[0] - p[0];
667        let params = [5.0];
668        let names = ["x"];
669
670        let result = compute_sensitivities(f, &params, &names, None);
671        assert!(result.output.abs() < 1e-15);
672        // Normalized should be zero because output is zero
673        assert!(result.sensitivities[0].normalized.abs() < 1e-10);
674    }
675}