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