Skip to main content

numra_ode/
uncertainty.rs

1//! Uncertainty propagation in ODE solutions.
2//!
3//! This module provides tools for propagating parameter uncertainties
4//! through ODE solutions. Given an ODE system:
5//!
6//! ```text
7//! dy/dt = f(t, y, p),  y(t0) = y0
8//! ```
9//!
10//! where the parameters `p` have associated uncertainties `sigma_p`, this
11//! module computes uncertainty bands on the solution trajectory `y(t)`.
12//!
13//! Two modes are supported:
14//!
15//! - **Trajectory (GUM)**: First-order Taylor propagation via forward sensitivity
16//!   equations. At each time `t`:
17//!   `sigma_y_j(t)^2 = sum_k (dy_j/dp_k)^2 * sigma_p_k^2`
18//!
19//! - **Monte Carlo**: Sample parameters from distributions, solve N times,
20//!   compute mean and standard deviation of the ensemble.
21//!
22//! # Composability
23//!
24//! The main entry point [`solve_with_uncertainty`] is generic over the solver:
25//!
26//! ```text
27//! solve_with_uncertainty::<DoPri5, f64>(...) // explicit solver
28//! solve_with_uncertainty::<Radau5, f64>(...) // stiff solver
29//! solve_with_uncertainty::<Auto, f64>(...)   // auto-selected
30//! ```
31//!
32//! Any solver implementing the `Solver<S>` trait works without modification.
33//!
34//! Author: Moussa Leblouba
35//! Date: 9 February 2026
36//! Modified: 2 May 2026
37
38use numra_core::uncertainty::Uncertain;
39use numra_core::Scalar;
40
41use crate::error::SolverError;
42use crate::problem::OdeSystem;
43use crate::sensitivity::solve_forward_sensitivity_with;
44use crate::solver::{Solver, SolverOptions, SolverResult, SolverStats};
45
46// ---------------------------------------------------------------------------
47// Types
48// ---------------------------------------------------------------------------
49
50/// Uncertainty propagation mode.
51#[derive(Clone, Debug)]
52pub enum UncertaintyMode {
53    /// First-order Taylor series (GUM method) via sensitivity equations.
54    Trajectory,
55    /// Monte Carlo sampling.
56    MonteCarlo {
57        /// Number of Monte Carlo samples.
58        n_samples: usize,
59    },
60}
61
62/// A parameter with uncertainty.
63#[derive(Clone, Debug)]
64pub struct UncertainParam<S: Scalar> {
65    /// Parameter name (for reporting).
66    pub name: String,
67    /// Nominal (mean) value.
68    pub nominal: S,
69    /// Standard deviation.
70    pub std: S,
71}
72
73impl<S: Scalar> UncertainParam<S> {
74    /// Create a new uncertain parameter.
75    pub fn new(name: impl Into<String>, nominal: S, std: S) -> Self {
76        Self {
77            name: name.into(),
78            nominal,
79            std,
80        }
81    }
82
83    /// Create from an `Uncertain<S>` value with a name.
84    pub fn from_uncertain(name: impl Into<String>, u: Uncertain<S>) -> Self {
85        Self {
86            name: name.into(),
87            nominal: u.mean,
88            std: u.std(),
89        }
90    }
91
92    /// Variance (sigma^2).
93    pub fn variance(&self) -> S {
94        self.std * self.std
95    }
96}
97
98/// ODE result with uncertainty bands.
99#[derive(Clone, Debug)]
100pub struct UncertainSolverResult<S: Scalar> {
101    /// Base result (nominal trajectory).
102    pub result: SolverResult<S>,
103    /// Standard deviation at each output time, for each component.
104    /// `sigma[i * dim + j]` = std dev of component j at time i.
105    pub sigma: Vec<S>,
106    /// Sensitivity matrices at each output time (Trajectory mode only).
107    /// Each entry is a flat row-major matrix of shape `(n_states, n_params)`.
108    /// `sensitivities[i][j * n_params + k]` = dy_j/dp_k at time i.
109    pub sensitivities: Option<Vec<Vec<S>>>,
110    /// Parameter information.
111    pub params: Vec<UncertainParam<S>>,
112}
113
114impl<S: Scalar> UncertainSolverResult<S> {
115    /// Get sigma for component `j` at time index `i`.
116    pub fn sigma_at(&self, i: usize, j: usize) -> S {
117        self.sigma[i * self.result.dim + j]
118    }
119
120    /// Get the uncertain value (mean +/- std) for component `j` at time index `i`.
121    pub fn uncertain_at(&self, i: usize, j: usize) -> Uncertain<S> {
122        let mean = self.result.y_at(i)[j];
123        let std = self.sigma_at(i, j);
124        Uncertain::from_std(mean, std)
125    }
126
127    /// Get sensitivity dy_j/dp_k at time index `i`.
128    /// Returns `None` if sensitivities were not computed (Monte Carlo mode).
129    pub fn sensitivity_at(&self, i: usize, j: usize, k: usize) -> Option<S> {
130        self.sensitivities.as_ref().map(|sens| {
131            let n_params = self.params.len();
132            sens[i][j * n_params + k]
133        })
134    }
135
136    /// Number of time points.
137    pub fn len(&self) -> usize {
138        self.result.len()
139    }
140
141    /// Whether the result is empty.
142    pub fn is_empty(&self) -> bool {
143        self.result.is_empty()
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Trajectory mode
149// ---------------------------------------------------------------------------
150
151/// Solve an ODE with uncertainty propagation using first-order Taylor (GUM)
152/// via forward sensitivity equations.
153///
154/// This is solver-agnostic: the caller chooses the solver via the type
155/// parameter `Sol`.
156///
157/// # Arguments
158///
159/// * `model` -- Parameterized RHS: `f(t, y, dydt, params)`.
160/// * `y0` -- Initial state.
161/// * `t0`, `tf` -- Integration interval.
162/// * `params` -- Parameters with uncertainties.
163/// * `options` -- Solver options (rtol, atol, etc.).
164///
165/// # Returns
166///
167/// An [`UncertainSolverResult`] with the nominal trajectory, sigma bands,
168/// and the full sensitivity matrices.
169pub fn solve_trajectory<Sol, S, F>(
170    model: F,
171    y0: &[S],
172    t0: S,
173    tf: S,
174    params: &[UncertainParam<S>],
175    options: &SolverOptions<S>,
176) -> Result<UncertainSolverResult<S>, SolverError>
177where
178    S: Scalar,
179    Sol: Solver<S>,
180    F: Fn(S, &[S], &mut [S], &[S]),
181{
182    let n_states = y0.len();
183    let n_params = params.len();
184    let nominal_params: Vec<S> = params.iter().map(|p| p.nominal).collect();
185    let variances: Vec<S> = params.iter().map(|p| p.variance()).collect();
186
187    // Adapt argument order: solve_forward_sensitivity_with expects
188    // `(t, y, p, dydt)`; this module's public model has `(t, y, dydt, p)`.
189    let rhs = move |t: S, y: &[S], p: &[S], dydt: &mut [S]| {
190        model(t, y, dydt, p);
191    };
192
193    let sens =
194        solve_forward_sensitivity_with::<Sol, S, _>(rhs, y0, &nominal_params, t0, tf, options)?;
195
196    if !sens.success {
197        return Err(SolverError::Other(sens.message));
198    }
199
200    // The canonical primitive returns sensitivity in column-major (per-time
201    // block of length `n_states * n_params`, with `block[k*n_states + j] =
202    // ∂y_j/∂p_k`). The public layout for `UncertainSolverResult.sensitivities`
203    // is row-major (state-major) per
204    //   `sensitivities[i][j*n_params + k] = ∂y_j/∂p_k`.
205    // Transpose during the copy so the public-facing accessor semantics stay
206    // unchanged.
207    let n_times = sens.len();
208    let mut sens_out = Vec::with_capacity(n_times);
209    let mut sigma_out = Vec::with_capacity(n_times * n_states);
210
211    for i in 0..n_times {
212        let block = sens.sensitivity_at(i);
213        let mut row_major = vec![S::ZERO; n_states * n_params];
214        for j in 0..n_states {
215            for k in 0..n_params {
216                row_major[j * n_params + k] = block[k * n_states + j];
217            }
218        }
219        sens_out.push(row_major);
220
221        // GUM propagation: σ_{y_j}^2 = Σ_k (∂y_j/∂p_k)^2 · σ_{p_k}^2.
222        for j in 0..n_states {
223            let mut var_j = S::ZERO;
224            for k in 0..n_params {
225                let dydp = block[k * n_states + j];
226                var_j = var_j + dydp * dydp * variances[k];
227            }
228            sigma_out.push(var_j.sqrt());
229        }
230    }
231
232    let nominal_result = SolverResult {
233        t: sens.t,
234        y: sens.y,
235        dim: n_states,
236        stats: sens.stats,
237        success: true,
238        message: String::new(),
239        events: Vec::new(),
240        terminated_by_event: false,
241        dense_output: None,
242    };
243
244    Ok(UncertainSolverResult {
245        result: nominal_result,
246        sigma: sigma_out,
247        sensitivities: Some(sens_out),
248        params: params.to_vec(),
249    })
250}
251
252// ---------------------------------------------------------------------------
253// Monte Carlo mode
254// ---------------------------------------------------------------------------
255
256/// Solve an ODE with uncertainty propagation using Monte Carlo sampling.
257///
258/// Samples parameter vectors from independent normal distributions, solves
259/// the ODE `n_samples` times, and computes mean and standard deviation of
260/// the ensemble at each output time.
261///
262/// # Arguments
263///
264/// * `model` -- Parameterized RHS: `f(t, y, dydt, params)`.
265/// * `y0` -- Initial state.
266/// * `t0`, `tf` -- Integration interval.
267/// * `params` -- Parameters with uncertainties.
268/// * `n_samples` -- Number of Monte Carlo samples.
269/// * `options` -- Solver options.
270/// * `seed` -- Random seed for reproducibility.
271///
272/// The `model` argument is a factory that creates a fresh closure for each
273/// parameter sample, ensuring thread-safety.
274///
275/// # Returns
276///
277/// An [`UncertainSolverResult`] with the mean trajectory and sigma bands.
278/// Sensitivities are `None` in Monte Carlo mode.
279pub fn solve_monte_carlo<Sol, S, F>(
280    model: F,
281    y0: &[S],
282    t0: S,
283    tf: S,
284    params: &[UncertainParam<S>],
285    n_samples: usize,
286    options: &SolverOptions<S>,
287    seed: u64,
288) -> Result<UncertainSolverResult<S>, SolverError>
289where
290    S: Scalar,
291    Sol: Solver<S>,
292    F: Fn(S, &[S], &mut [S], &[S]) + Send + Sync,
293{
294    let n_states = y0.len();
295    let n_params = params.len();
296
297    // First, solve the nominal problem to get the reference trajectory
298    let nominal_params: Vec<S> = params.iter().map(|p| p.nominal).collect();
299    let nominal_sys = ParameterizedWrapper {
300        model: &model,
301        params: nominal_params.clone(),
302        n_dim: n_states,
303    };
304
305    let nominal_result = Sol::solve(&nominal_sys, t0, tf, y0, options)?;
306    if !nominal_result.success {
307        return Err(SolverError::Other(nominal_result.message));
308    }
309
310    let n_times = nominal_result.len();
311
312    // For MC, we collect statistics at the final time only, since not all
313    // solvers honor t_eval and adaptive step counts vary across samples.
314    // We then scale the nominal trajectory's sigma using the final-time ratio.
315    let mut sum_final = vec![S::ZERO; n_states];
316    let mut sum_sq_final = vec![S::ZERO; n_states];
317    let mut n_success: usize = 0;
318
319    let mut rng_state = seed;
320
321    for _ in 0..n_samples {
322        // Sample parameters from independent normals
323        let mut p_sample = Vec::with_capacity(n_params);
324        for param in params {
325            let z = box_muller_sample(&mut rng_state);
326            let p_val = param.nominal + param.std * S::from_f64(z);
327            p_sample.push(p_val);
328        }
329
330        let sample_sys = ParameterizedWrapper {
331            model: &model,
332            params: p_sample,
333            n_dim: n_states,
334        };
335
336        match Sol::solve(&sample_sys, t0, tf, y0, options) {
337            Ok(result) if result.success => {
338                if let Some(y_final) = result.y_final() {
339                    n_success += 1;
340                    for j in 0..n_states {
341                        sum_final[j] = sum_final[j] + y_final[j];
342                        sum_sq_final[j] = sum_sq_final[j] + y_final[j] * y_final[j];
343                    }
344                }
345            }
346            _ => {
347                // Skip failed samples
348            }
349        }
350    }
351
352    if n_success < 2 {
353        return Err(SolverError::Other(
354            "Monte Carlo: fewer than 2 samples succeeded".to_string(),
355        ));
356    }
357
358    // Compute final-time sigma from MC ensemble
359    let n_s = S::from_usize(n_success);
360    let mut sigma_final = Vec::with_capacity(n_states);
361    for j in 0..n_states {
362        let mean = sum_final[j] / n_s;
363        let var = (sum_sq_final[j] / n_s - mean * mean) * n_s / (n_s - S::ONE);
364        let std = if var > S::ZERO { var.sqrt() } else { S::ZERO };
365        sigma_final.push(std);
366    }
367
368    // Build sigma for all time points by linearly interpolating from 0 at t0
369    // to sigma_final at tf. This is an approximation — for exact MC at all
370    // times, use segment-by-segment integration (see numra-ocp).
371    let mut sigma = Vec::with_capacity(n_times * n_states);
372    for i in 0..n_times {
373        let frac = if n_times > 1 {
374            S::from_usize(i) / S::from_usize(n_times - 1)
375        } else {
376            S::ONE
377        };
378        for j in 0..n_states {
379            sigma.push(sigma_final[j] * frac);
380        }
381    }
382
383    let mc_result = SolverResult {
384        t: nominal_result.t.clone(),
385        y: nominal_result.y.clone(),
386        dim: n_states,
387        stats: SolverStats::new(),
388        success: true,
389        message: format!("{}/{} samples succeeded", n_success, n_samples),
390        events: Vec::new(),
391        terminated_by_event: false,
392        dense_output: None,
393    };
394
395    Ok(UncertainSolverResult {
396        result: mc_result,
397        sigma,
398        sensitivities: None,
399        params: params.to_vec(),
400    })
401}
402
403// ---------------------------------------------------------------------------
404// Unified entry point
405// ---------------------------------------------------------------------------
406
407/// Solve an ODE with uncertainty propagation.
408///
409/// This is the main composable entry point. Choose your solver via the type
410/// parameter and your propagation mode via `UncertaintyMode`.
411///
412/// # Example (conceptual)
413///
414/// ```text
415/// let params = vec![
416///     UncertainParam::new("k", 0.5, 0.05),
417/// ];
418///
419/// let result = solve_with_uncertainty::<DoPri5, f64, _>(
420///     |t, y, dydt, p| { dydt[0] = -p[0] * y[0]; },
421///     &[1.0],
422///     0.0, 5.0,
423///     &params,
424///     &UncertaintyMode::Trajectory,
425///     &SolverOptions::default(),
426///     None,
427/// )?;
428///
429/// // result.sigma_at(i, j) gives std dev of component j at time i
430/// ```
431pub fn solve_with_uncertainty<Sol, S, F>(
432    model: F,
433    y0: &[S],
434    t0: S,
435    tf: S,
436    params: &[UncertainParam<S>],
437    mode: &UncertaintyMode,
438    options: &SolverOptions<S>,
439    seed: Option<u64>,
440) -> Result<UncertainSolverResult<S>, SolverError>
441where
442    S: Scalar,
443    Sol: Solver<S>,
444    F: Fn(S, &[S], &mut [S], &[S]) + Send + Sync,
445{
446    match mode {
447        UncertaintyMode::Trajectory => {
448            solve_trajectory::<Sol, S, F>(model, y0, t0, tf, params, options)
449        }
450        UncertaintyMode::MonteCarlo { n_samples } => solve_monte_carlo::<Sol, S, F>(
451            model,
452            y0,
453            t0,
454            tf,
455            params,
456            *n_samples,
457            options,
458            seed.unwrap_or(42),
459        ),
460    }
461}
462
463// ---------------------------------------------------------------------------
464// Helpers
465// ---------------------------------------------------------------------------
466
467/// Wrapper that turns a parameterized model `f(t, y, dydt, params)` into
468/// an `OdeSystem` with fixed parameters.
469struct ParameterizedWrapper<'a, S: Scalar, F> {
470    model: &'a F,
471    params: Vec<S>,
472    n_dim: usize,
473}
474
475impl<S: Scalar, F> OdeSystem<S> for ParameterizedWrapper<'_, S, F>
476where
477    F: Fn(S, &[S], &mut [S], &[S]) + Send + Sync,
478{
479    fn dim(&self) -> usize {
480        self.n_dim
481    }
482
483    fn rhs(&self, t: S, y: &[S], dydt: &mut [S]) {
484        (self.model)(t, y, dydt, &self.params);
485    }
486}
487
488/// Simple splitmix64 PRNG (better statistical properties than xorshift for
489/// Box-Muller).
490fn splitmix64(state: &mut u64) -> u64 {
491    *state = state.wrapping_add(0x9e3779b97f4a7c15);
492    let mut z = *state;
493    z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
494    z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
495    z ^ (z >> 31)
496}
497
498/// Generate a standard normal sample via Box-Muller transform.
499fn box_muller_sample(state: &mut u64) -> f64 {
500    loop {
501        let u1 = ((splitmix64(state) >> 11) as f64) / ((1u64 << 53) as f64);
502        let u2 = ((splitmix64(state) >> 11) as f64) / ((1u64 << 53) as f64);
503        if u1 > 1e-15 {
504            let r = (-2.0 * u1.ln()).sqrt();
505            return r * (2.0 * core::f64::consts::PI * u2).cos();
506        }
507    }
508}
509
510// ---------------------------------------------------------------------------
511// Tests
512// ---------------------------------------------------------------------------
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::{DoPri5, Radau5};
518
519    /// Exponential decay: dy/dt = -k*y, y(0) = 1, k = 0.5 +/- 0.05.
520    ///
521    /// Exact solution: y(t) = exp(-k*t).
522    /// Exact sensitivity: dy/dk = -t * exp(-k*t).
523    /// Exact sigma_y(t) = |dy/dk| * sigma_k = t * exp(-k*t) * sigma_k.
524    #[test]
525    fn test_trajectory_exponential_decay() {
526        let params = vec![UncertainParam::new("k", 0.5, 0.05)];
527
528        let result = solve_with_uncertainty::<DoPri5, f64, _>(
529            |_t, y, dydt, p| {
530                dydt[0] = -p[0] * y[0];
531            },
532            &[1.0],
533            0.0,
534            5.0,
535            &params,
536            &UncertaintyMode::Trajectory,
537            &SolverOptions::default().rtol(1e-8).atol(1e-10),
538            None,
539        )
540        .expect("solve_with_uncertainty failed");
541
542        assert!(result.result.success);
543        assert!(!result.is_empty());
544
545        // Check sigma at the final time
546        let n = result.len();
547        let t_final = result.result.t[n - 1];
548        let k = 0.5;
549        let sigma_k = 0.05;
550
551        let exact_sigma = t_final * (-k * t_final).exp() * sigma_k;
552        let computed_sigma = result.sigma_at(n - 1, 0);
553
554        assert!(
555            (computed_sigma - exact_sigma).abs() < 0.001,
556            "sigma: computed={}, exact={}, err={}",
557            computed_sigma,
558            exact_sigma,
559            (computed_sigma - exact_sigma).abs()
560        );
561
562        // Check that sensitivity is available
563        assert!(result.sensitivities.is_some());
564        let dydp = result.sensitivity_at(n - 1, 0, 0).unwrap();
565        let exact_dydp = -t_final * (-k * t_final).exp();
566        assert!(
567            (dydp - exact_dydp).abs() < 0.001,
568            "dy/dk: computed={}, exact={}, err={}",
569            dydp,
570            exact_dydp,
571            (dydp - exact_dydp).abs()
572        );
573    }
574
575    /// Two-parameter model: dy/dt = -a*y + b, y(0) = 1.
576    /// a = 1.0 +/- 0.1, b = 2.0 +/- 0.2.
577    #[test]
578    fn test_trajectory_two_params() {
579        let params = vec![
580            UncertainParam::new("a", 1.0, 0.1),
581            UncertainParam::new("b", 2.0, 0.2),
582        ];
583
584        let result = solve_trajectory::<DoPri5, f64, _>(
585            |_t, y, dydt, p| {
586                dydt[0] = -p[0] * y[0] + p[1];
587            },
588            &[1.0],
589            0.0,
590            3.0,
591            &params,
592            &SolverOptions::default().rtol(1e-8).atol(1e-10),
593        )
594        .expect("solve_trajectory failed");
595
596        assert!(result.result.success);
597
598        // At t=3, solution should be near the steady state b/a = 2.0.
599        let n = result.len();
600        let y_final = result.result.y_at(n - 1)[0];
601        assert!(
602            (y_final - 2.0).abs() < 0.1,
603            "y(3) = {}, expected near 2.0",
604            y_final
605        );
606
607        // Sigma should be > 0 (uncertainty propagated)
608        let sigma_final = result.sigma_at(n - 1, 0);
609        assert!(
610            sigma_final > 0.0,
611            "sigma should be positive: {}",
612            sigma_final
613        );
614    }
615
616    /// Verify that Trajectory and Monte Carlo modes give consistent results.
617    #[test]
618    fn test_trajectory_vs_monte_carlo() {
619        let params = vec![UncertainParam::new("k", 0.5, 0.05)];
620
621        let traj_result = solve_with_uncertainty::<DoPri5, f64, _>(
622            |_t, y, dydt, p| {
623                dydt[0] = -p[0] * y[0];
624            },
625            &[1.0],
626            0.0,
627            2.0,
628            &params,
629            &UncertaintyMode::Trajectory,
630            &SolverOptions::default().rtol(1e-8).atol(1e-10),
631            None,
632        )
633        .expect("trajectory failed");
634
635        let mc_result = solve_with_uncertainty::<DoPri5, f64, _>(
636            |_t, y, dydt, p| {
637                dydt[0] = -p[0] * y[0];
638            },
639            &[1.0],
640            0.0,
641            2.0,
642            &params,
643            &UncertaintyMode::MonteCarlo { n_samples: 5000 },
644            &SolverOptions::default().rtol(1e-8).atol(1e-10),
645            Some(12345),
646        )
647        .expect("monte carlo failed");
648
649        // Final sigma should agree within ~10% (MC has statistical noise)
650        let n_traj = traj_result.len();
651        let n_mc = mc_result.len();
652        let sigma_traj = traj_result.sigma_at(n_traj - 1, 0);
653        let sigma_mc = mc_result.sigma_at(n_mc - 1, 0);
654
655        let rel_diff = (sigma_traj - sigma_mc).abs() / sigma_traj;
656        assert!(
657            rel_diff < 0.15,
658            "Trajectory sigma={}, MC sigma={}, rel_diff={}",
659            sigma_traj,
660            sigma_mc,
661            rel_diff
662        );
663    }
664
665    /// Composability test: same problem with a stiff solver (Radau5).
666    #[test]
667    fn test_composability_stiff_solver() {
668        let params = vec![UncertainParam::new("k", 50.0, 5.0)];
669
670        let result = solve_trajectory::<Radau5, f64, _>(
671            |_t, y, dydt, p| {
672                dydt[0] = -p[0] * y[0];
673            },
674            &[1.0],
675            0.0,
676            0.2,
677            &params,
678            &SolverOptions::default().rtol(1e-4).atol(1e-7).h0(1e-4),
679        )
680        .expect("stiff solve failed");
681
682        assert!(result.result.success);
683        assert!(!result.is_empty());
684
685        // Verify sigma > 0 at final time
686        let n = result.len();
687        let sigma = result.sigma_at(n - 1, 0);
688        assert!(sigma >= 0.0, "sigma should be non-negative: {}", sigma);
689    }
690
691    /// Multi-component system: Lotka-Volterra with uncertain parameters.
692    #[test]
693    fn test_trajectory_lotka_volterra() {
694        let params = vec![
695            UncertainParam::new("alpha", 1.0, 0.1),
696            UncertainParam::new("beta", 0.1, 0.01),
697            UncertainParam::new("delta", 0.075, 0.005),
698            UncertainParam::new("gamma", 1.5, 0.1),
699        ];
700
701        let result = solve_trajectory::<DoPri5, f64, _>(
702            |_t, y, dydt, p| {
703                let x = y[0];
704                let yy = y[1];
705                dydt[0] = p[0] * x - p[1] * x * yy;
706                dydt[1] = p[2] * x * yy - p[3] * yy;
707            },
708            &[10.0, 5.0],
709            0.0,
710            5.0,
711            &params,
712            &SolverOptions::default().rtol(1e-8).atol(1e-10),
713        )
714        .expect("Lotka-Volterra solve failed");
715
716        assert!(result.result.success);
717        assert_eq!(result.result.dim, 2);
718
719        // Both components should have sigma > 0
720        let n = result.len();
721        let sigma_prey = result.sigma_at(n - 1, 0);
722        let sigma_pred = result.sigma_at(n - 1, 1);
723        assert!(sigma_prey > 0.0, "prey sigma should be positive");
724        assert!(sigma_pred > 0.0, "predator sigma should be positive");
725
726        // All 8 sensitivities should exist (2 states x 4 params)
727        let sens = result.sensitivities.as_ref().unwrap();
728        assert_eq!(sens[n - 1].len(), 2 * 4);
729    }
730
731    /// Test UncertainParam::from_uncertain conversion.
732    #[test]
733    fn test_uncertain_param_from_uncertain() {
734        let u = Uncertain::from_std(5.0, 0.5);
735        let p = UncertainParam::from_uncertain("x", u);
736        assert!((p.nominal - 5.0).abs() < 1e-10);
737        assert!((p.std - 0.5).abs() < 1e-10);
738        assert!((p.variance() - 0.25).abs() < 1e-10);
739    }
740
741    /// Verify that zero uncertainty produces zero sigma.
742    #[test]
743    fn test_zero_uncertainty() {
744        let params = vec![UncertainParam::new("k", 0.5, 0.0)];
745
746        let result = solve_trajectory::<DoPri5, f64, _>(
747            |_t, y, dydt, p| {
748                dydt[0] = -p[0] * y[0];
749            },
750            &[1.0],
751            0.0,
752            2.0,
753            &params,
754            &SolverOptions::default(),
755        )
756        .expect("solve failed");
757
758        // Sigma should be 0 everywhere
759        for i in 0..result.len() {
760            let sigma = result.sigma_at(i, 0);
761            assert!(
762                sigma.abs() < 1e-10,
763                "sigma should be ~0 at t={}: got {}",
764                result.result.t[i],
765                sigma
766            );
767        }
768    }
769
770    /// Test the uncertain_at helper that returns Uncertain<S> values.
771    #[test]
772    fn test_uncertain_at() {
773        let params = vec![UncertainParam::new("k", 0.5, 0.05)];
774
775        let result = solve_trajectory::<DoPri5, f64, _>(
776            |_t, y, dydt, p| {
777                dydt[0] = -p[0] * y[0];
778            },
779            &[1.0],
780            0.0,
781            1.0,
782            &params,
783            &SolverOptions::default().rtol(1e-8),
784        )
785        .expect("solve failed");
786
787        let n = result.len();
788        let u = result.uncertain_at(n - 1, 0);
789        assert!(u.mean > 0.0);
790        assert!(u.variance > 0.0);
791        assert!((u.std() - result.sigma_at(n - 1, 0)).abs() < 1e-14);
792    }
793}