1use 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#[derive(Clone, Debug)]
54pub enum UncertaintyMode {
55 Trajectory,
57 MonteCarlo {
59 n_samples: usize,
61 },
62}
63
64#[derive(Clone, Debug)]
66pub struct UncertainParam<S: Scalar> {
67 pub name: String,
69 pub nominal: S,
71 pub std: S,
73}
74
75impl<S: Scalar> UncertainParam<S> {
76 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 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 pub fn variance(&self) -> S {
96 self.std * self.std
97 }
98}
99
100#[derive(Clone, Debug)]
102pub struct UncertainSolverResult<S: Scalar> {
103 pub result: SolverResult<S>,
105 pub sigma: Vec<S>,
108 pub sensitivities: Option<Vec<Vec<S>>>,
112 pub params: Vec<UncertainParam<S>>,
114}
115
116impl<S: Scalar> UncertainSolverResult<S> {
117 pub fn sigma_at(&self, i: usize, j: usize) -> S {
119 self.sigma[i * self.result.dim + j]
120 }
121
122 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 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 pub fn len(&self) -> usize {
140 self.result.len()
141 }
142
143 pub fn is_empty(&self) -> bool {
145 self.result.is_empty()
146 }
147}
148
149pub 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 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 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 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
254pub 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 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 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 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 }
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 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 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
405pub 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
465struct 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
490fn 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
500fn 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#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::{DoPri5, Radau5};
520
521 #[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 ¶ms,
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 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 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 #[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 ¶ms,
594 &SolverOptions::default().rtol(1e-8).atol(1e-10),
595 )
596 .expect("solve_trajectory failed");
597
598 assert!(result.result.success);
599
600 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 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 #[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 ¶ms,
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 ¶ms,
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 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 #[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 ¶ms,
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 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 #[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 ¶ms,
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 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 let sens = result.sensitivities.as_ref().unwrap();
730 assert_eq!(sens[n - 1].len(), 2 * 4);
731 }
732
733 #[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 #[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 ¶ms,
756 &SolverOptions::default(),
757 )
758 .expect("solve failed");
759
760 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]
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 ¶ms,
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}