Skip to main content

simular/scenarios/
epidemic.rs

1//! Epidemic compartmental model scenarios.
2//!
3//! Implements classical epidemiological models:
4//! - SIR (Susceptible-Infected-Recovered)
5//! - SEIR (with Exposed compartment)
6//! - SEIRS (with waning immunity)
7//! - Stochastic variants for Monte Carlo analysis
8
9use crate::engine::rng::SimRng;
10use crate::error::{SimError, SimResult};
11use serde::{Deserialize, Serialize};
12
13/// Configuration for SIR model.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SIRConfig {
16    /// Total population size.
17    pub population: f64,
18    /// Initial number of infected individuals.
19    pub initial_infected: f64,
20    /// Initial number of recovered individuals.
21    pub initial_recovered: f64,
22    /// Transmission rate (β): contacts per unit time × transmission probability.
23    pub beta: f64,
24    /// Recovery rate (γ): 1 / infectious period.
25    pub gamma: f64,
26}
27
28impl Default for SIRConfig {
29    fn default() -> Self {
30        Self {
31            population: 1_000_000.0,
32            initial_infected: 100.0,
33            initial_recovered: 0.0,
34            beta: 0.3,  // R0 = β/γ = 3.0 (like early COVID)
35            gamma: 0.1, // 10-day infectious period
36        }
37    }
38}
39
40impl SIRConfig {
41    /// Create configuration for seasonal flu.
42    #[must_use]
43    pub fn flu() -> Self {
44        Self {
45            population: 1_000_000.0,
46            initial_infected: 10.0,
47            initial_recovered: 0.0,
48            beta: 0.2, // R0 ≈ 1.3
49            gamma: 0.15,
50        }
51    }
52
53    /// Create configuration for measles (highly contagious).
54    #[must_use]
55    pub fn measles() -> Self {
56        Self {
57            population: 1_000_000.0,
58            initial_infected: 1.0,
59            initial_recovered: 0.0,
60            beta: 1.8, // R0 ≈ 15 (one of the most contagious)
61            gamma: 0.12,
62        }
63    }
64
65    /// Calculate basic reproduction number R0.
66    ///
67    /// Returns 0.0 if gamma is zero (no recovery → undefined R0).
68    #[must_use]
69    pub fn r0(&self) -> f64 {
70        if self.gamma == 0.0 {
71            return 0.0;
72        }
73        self.beta / self.gamma
74    }
75
76    /// Calculate herd immunity threshold.
77    ///
78    /// Returns 0.0 if R0 is zero.
79    #[must_use]
80    pub fn herd_immunity_threshold(&self) -> f64 {
81        let r0 = self.r0();
82        if r0 == 0.0 {
83            return 0.0;
84        }
85        1.0 - 1.0 / r0
86    }
87
88    /// Calculate initial susceptible population.
89    #[must_use]
90    pub fn initial_susceptible(&self) -> f64 {
91        self.population - self.initial_infected - self.initial_recovered
92    }
93}
94
95/// Configuration for SEIR model (adds Exposed compartment).
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SEIRConfig {
98    /// Base SIR configuration.
99    pub sir: SIRConfig,
100    /// Initial number of exposed (infected but not yet infectious).
101    pub initial_exposed: f64,
102    /// Incubation rate (σ): 1 / incubation period.
103    pub sigma: f64,
104}
105
106impl Default for SEIRConfig {
107    fn default() -> Self {
108        Self {
109            sir: SIRConfig::default(),
110            initial_exposed: 50.0,
111            sigma: 0.2, // 5-day incubation period
112        }
113    }
114}
115
116impl SEIRConfig {
117    /// Calculate initial susceptible population (accounting for exposed).
118    #[must_use]
119    pub fn initial_susceptible(&self) -> f64 {
120        self.sir.population
121            - self.sir.initial_infected
122            - self.sir.initial_recovered
123            - self.initial_exposed
124    }
125}
126
127/// State of SIR model at a point in time.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct SIRState {
130    /// Number of susceptible individuals.
131    pub susceptible: f64,
132    /// Number of infected individuals.
133    pub infected: f64,
134    /// Number of recovered individuals.
135    pub recovered: f64,
136    /// Current time.
137    pub time: f64,
138    /// Effective reproduction number Rt.
139    pub rt: f64,
140}
141
142impl SIRState {
143    /// Calculate total population.
144    #[must_use]
145    pub fn total(&self) -> f64 {
146        self.susceptible + self.infected + self.recovered
147    }
148
149    /// Calculate fraction infected.
150    #[must_use]
151    pub fn infection_rate(&self) -> f64 {
152        let total = self.total();
153        if total == 0.0 {
154            0.0
155        } else {
156            self.infected / total
157        }
158    }
159
160    /// Calculate fraction susceptible.
161    #[must_use]
162    pub fn susceptible_rate(&self) -> f64 {
163        let total = self.total();
164        if total == 0.0 {
165            0.0
166        } else {
167            self.susceptible / total
168        }
169    }
170
171    /// Check if epidemic is over (no more infected).
172    #[must_use]
173    pub fn is_over(&self) -> bool {
174        self.infected < 1.0
175    }
176}
177
178/// State of SEIR model.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct SEIRState {
181    /// Base SIR state.
182    pub sir: SIRState,
183    /// Number of exposed individuals.
184    pub exposed: f64,
185}
186
187impl SEIRState {
188    /// Calculate total population.
189    #[must_use]
190    pub fn total(&self) -> f64 {
191        self.sir.susceptible + self.exposed + self.sir.infected + self.sir.recovered
192    }
193}
194
195/// SIR epidemic scenario.
196#[derive(Debug, Clone)]
197pub struct SIRScenario {
198    config: SIRConfig,
199    state: SIRState,
200}
201
202impl SIRScenario {
203    /// Create a new SIR scenario.
204    ///
205    /// # Panics
206    ///
207    /// Panics if `population` or `gamma` is not positive.
208    #[must_use]
209    #[allow(clippy::many_single_char_names)]
210    pub fn new(config: SIRConfig) -> Self {
211        assert!(config.population > 0.0, "Population must be > 0");
212        assert!(config.gamma > 0.0, "Recovery rate (gamma) must be > 0");
213
214        let s = config.initial_susceptible();
215        let i = config.initial_infected;
216        let r = config.initial_recovered;
217        let n = config.population;
218
219        let state = SIRState {
220            susceptible: s,
221            infected: i,
222            recovered: r,
223            time: 0.0,
224            rt: config.r0() * (s / n),
225        };
226
227        Self { config, state }
228    }
229
230    /// Step forward using 4th-order Runge-Kutta.
231    ///
232    /// # Errors
233    ///
234    /// Returns an error if state becomes non-physical or population is not conserved.
235    #[allow(clippy::many_single_char_names)]
236    pub fn step(&mut self, dt: f64) -> SimResult<&SIRState> {
237        let n = self.config.population;
238        let beta = self.config.beta;
239        let gamma = self.config.gamma;
240
241        // Current state
242        let s = self.state.susceptible;
243        let i = self.state.infected;
244        let r = self.state.recovered;
245
246        // RK4 integration
247        let (k1_s, k1_i, k1_r) = self.derivatives(s, i, r, n, beta, gamma);
248        let (k2_s, k2_i, k2_r) = self.derivatives(
249            s + 0.5 * dt * k1_s,
250            i + 0.5 * dt * k1_i,
251            r + 0.5 * dt * k1_r,
252            n,
253            beta,
254            gamma,
255        );
256        let (k3_s, k3_i, k3_r) = self.derivatives(
257            s + 0.5 * dt * k2_s,
258            i + 0.5 * dt * k2_i,
259            r + 0.5 * dt * k2_r,
260            n,
261            beta,
262            gamma,
263        );
264        let (k4_s, k4_i, k4_r) =
265            self.derivatives(s + dt * k3_s, i + dt * k3_i, r + dt * k3_r, n, beta, gamma);
266
267        let new_s = s + dt / 6.0 * (k1_s + 2.0 * k2_s + 2.0 * k3_s + k4_s);
268        let new_i = i + dt / 6.0 * (k1_i + 2.0 * k2_i + 2.0 * k3_i + k4_i);
269        let new_r = r + dt / 6.0 * (k1_r + 2.0 * k2_r + 2.0 * k3_r + k4_r);
270
271        // Jidoka: Check for non-physical values
272        if new_s < 0.0 || new_i < 0.0 || new_r < 0.0 {
273            return Err(SimError::jidoka(format!(
274                "Non-physical state: S={new_s}, I={new_i}, R={new_r}"
275            )));
276        }
277
278        // Conservation check
279        let total = new_s + new_i + new_r;
280        if (total - n).abs() > 1.0 {
281            return Err(SimError::jidoka(format!(
282                "Population conservation violated: {total} != {n}"
283            )));
284        }
285
286        self.state.susceptible = new_s;
287        self.state.infected = new_i;
288        self.state.recovered = new_r;
289        self.state.time += dt;
290        self.state.rt = self.config.r0() * (new_s / n);
291
292        Ok(&self.state)
293    }
294
295    /// Calculate derivatives for SIR model.
296    #[inline]
297    #[allow(clippy::unused_self)]
298    #[allow(clippy::many_single_char_names)]
299    fn derivatives(
300        &self,
301        s: f64,
302        i: f64,
303        _r: f64,
304        n: f64,
305        beta: f64,
306        gamma: f64,
307    ) -> (f64, f64, f64) {
308        let infection = beta * s * i / n;
309        let recovery = gamma * i;
310
311        let ds = -infection;
312        let di = infection - recovery;
313        let dr = recovery;
314
315        (ds, di, dr)
316    }
317
318    /// Run simulation until epidemic ends or max time.
319    ///
320    /// # Errors
321    ///
322    /// Returns an error if numerical integration fails (non-physical state).
323    pub fn run(&mut self, dt: f64, max_time: f64) -> SimResult<Vec<SIRState>> {
324        let mut trajectory = vec![self.state.clone()];
325
326        while self.state.time < max_time && !self.state.is_over() {
327            self.step(dt)?;
328            trajectory.push(self.state.clone());
329        }
330
331        Ok(trajectory)
332    }
333
334    /// Get current state.
335    #[must_use]
336    pub const fn state(&self) -> &SIRState {
337        &self.state
338    }
339
340    /// Get configuration.
341    #[must_use]
342    pub const fn config(&self) -> &SIRConfig {
343        &self.config
344    }
345
346    /// Calculate peak infected (analytically for SIR).
347    #[must_use]
348    pub fn peak_infected(&self) -> f64 {
349        let r0 = self.config.r0();
350        if r0 == 0.0 {
351            return 0.0;
352        }
353        let n = self.config.population;
354        let s0 = self.config.initial_susceptible();
355        let i0 = self.config.initial_infected;
356
357        if s0 <= 0.0 {
358            return i0;
359        }
360
361        // Peak occurs when dI/dt = 0, i.e., S = N/R0
362        let s_peak = n / r0;
363
364        // I_peak = S0 + I0 - S_peak + (N/R0) * ln(S_peak/S0)
365        s0 + i0 - s_peak + (n / r0) * (s_peak / s0).ln()
366    }
367
368    /// Calculate final epidemic size (analytically).
369    #[must_use]
370    pub fn final_size(&self) -> f64 {
371        let r0 = self.config.r0();
372        if r0 == 0.0 {
373            return 0.0;
374        }
375        let n = self.config.population;
376
377        // Solve: R_∞ = N * (1 - exp(-R0 * R_∞ / N))
378        // Using Newton's method
379        let mut r_inf = 0.8 * n; // Initial guess
380        for _ in 0..50 {
381            let f = r_inf - n * (1.0 - (-r0 * r_inf / n).exp());
382            let df = 1.0 - r0 * (-r0 * r_inf / n).exp();
383            if df.abs() < f64::EPSILON {
384                break;
385            }
386            r_inf -= f / df;
387        }
388
389        r_inf
390    }
391}
392
393/// SEIR epidemic scenario.
394#[derive(Debug, Clone)]
395pub struct SEIRScenario {
396    config: SEIRConfig,
397    state: SEIRState,
398}
399
400impl SEIRScenario {
401    /// Create a new SEIR scenario.
402    ///
403    /// # Panics
404    ///
405    /// Panics if `population` or `gamma` is not positive.
406    #[must_use]
407    #[allow(clippy::many_single_char_names)]
408    pub fn new(config: SEIRConfig) -> Self {
409        assert!(config.sir.population > 0.0, "Population must be > 0");
410        assert!(config.sir.gamma > 0.0, "Recovery rate (gamma) must be > 0");
411
412        let s = config.initial_susceptible();
413        let e = config.initial_exposed;
414        let i = config.sir.initial_infected;
415        let r = config.sir.initial_recovered;
416        let n = config.sir.population;
417
418        let state = SEIRState {
419            sir: SIRState {
420                susceptible: s,
421                infected: i,
422                recovered: r,
423                time: 0.0,
424                rt: config.sir.r0() * (s / n),
425            },
426            exposed: e,
427        };
428
429        Self { config, state }
430    }
431
432    /// Step forward using 4th-order Runge-Kutta.
433    ///
434    /// # Errors
435    ///
436    /// Returns an error if state becomes non-physical.
437    #[allow(clippy::many_single_char_names)]
438    pub fn step(&mut self, dt: f64) -> SimResult<&SEIRState> {
439        let n = self.config.sir.population;
440        let beta = self.config.sir.beta;
441        let gamma = self.config.sir.gamma;
442        let sigma = self.config.sigma;
443
444        // Current state
445        let s = self.state.sir.susceptible;
446        let e = self.state.exposed;
447        let i = self.state.sir.infected;
448        let r = self.state.sir.recovered;
449
450        // RK4 integration
451        let (k1_s, k1_e, k1_i, k1_r) = self.derivatives(s, e, i, r, n, beta, gamma, sigma);
452        let (k2_s, k2_e, k2_i, k2_r) = self.derivatives(
453            s + 0.5 * dt * k1_s,
454            e + 0.5 * dt * k1_e,
455            i + 0.5 * dt * k1_i,
456            r + 0.5 * dt * k1_r,
457            n,
458            beta,
459            gamma,
460            sigma,
461        );
462        let (k3_s, k3_e, k3_i, k3_r) = self.derivatives(
463            s + 0.5 * dt * k2_s,
464            e + 0.5 * dt * k2_e,
465            i + 0.5 * dt * k2_i,
466            r + 0.5 * dt * k2_r,
467            n,
468            beta,
469            gamma,
470            sigma,
471        );
472        let (k4_s, k4_e, k4_i, k4_r) = self.derivatives(
473            s + dt * k3_s,
474            e + dt * k3_e,
475            i + dt * k3_i,
476            r + dt * k3_r,
477            n,
478            beta,
479            gamma,
480            sigma,
481        );
482
483        let new_s = s + dt / 6.0 * (k1_s + 2.0 * k2_s + 2.0 * k3_s + k4_s);
484        let new_e = e + dt / 6.0 * (k1_e + 2.0 * k2_e + 2.0 * k3_e + k4_e);
485        let new_i = i + dt / 6.0 * (k1_i + 2.0 * k2_i + 2.0 * k3_i + k4_i);
486        let new_r = r + dt / 6.0 * (k1_r + 2.0 * k2_r + 2.0 * k3_r + k4_r);
487
488        // Jidoka: Check for non-physical values
489        if new_s < 0.0 || new_e < 0.0 || new_i < 0.0 || new_r < 0.0 {
490            return Err(SimError::jidoka(format!(
491                "Non-physical state: S={new_s}, E={new_e}, I={new_i}, R={new_r}"
492            )));
493        }
494
495        self.state.sir.susceptible = new_s;
496        self.state.exposed = new_e;
497        self.state.sir.infected = new_i;
498        self.state.sir.recovered = new_r;
499        self.state.sir.time += dt;
500        self.state.sir.rt = self.config.sir.r0() * (new_s / n);
501
502        Ok(&self.state)
503    }
504
505    /// Calculate derivatives for SEIR model.
506    #[inline]
507    #[allow(clippy::too_many_arguments)]
508    #[allow(clippy::unused_self)]
509    #[allow(clippy::many_single_char_names)]
510    fn derivatives(
511        &self,
512        s: f64,
513        e: f64,
514        i: f64,
515        _r: f64,
516        n: f64,
517        beta: f64,
518        gamma: f64,
519        sigma: f64,
520    ) -> (f64, f64, f64, f64) {
521        let infection = beta * s * i / n;
522        let incubation = sigma * e;
523        let recovery = gamma * i;
524
525        let ds = -infection;
526        let de = infection - incubation;
527        let di = incubation - recovery;
528        let dr = recovery;
529
530        (ds, de, di, dr)
531    }
532
533    /// Run simulation until epidemic ends or max time.
534    ///
535    /// # Errors
536    ///
537    /// Returns an error if numerical integration fails (non-physical state).
538    pub fn run(&mut self, dt: f64, max_time: f64) -> SimResult<Vec<SEIRState>> {
539        let mut trajectory = vec![self.state.clone()];
540
541        while self.state.sir.time < max_time
542            && (self.state.sir.infected > 1.0 || self.state.exposed > 1.0)
543        {
544            self.step(dt)?;
545            trajectory.push(self.state.clone());
546        }
547
548        Ok(trajectory)
549    }
550
551    /// Get current state.
552    #[must_use]
553    pub const fn state(&self) -> &SEIRState {
554        &self.state
555    }
556
557    /// Get configuration.
558    #[must_use]
559    pub const fn config(&self) -> &SEIRConfig {
560        &self.config
561    }
562}
563
564/// Stochastic SIR using Gillespie algorithm.
565#[derive(Debug, Clone)]
566pub struct StochasticSIR {
567    config: SIRConfig,
568    state: SIRState,
569}
570
571impl StochasticSIR {
572    /// Create a new stochastic SIR scenario.
573    ///
574    /// # Panics
575    ///
576    /// Panics if `population` or `gamma` is not positive.
577    #[must_use]
578    #[allow(clippy::many_single_char_names)]
579    pub fn new(config: SIRConfig) -> Self {
580        assert!(config.population > 0.0, "Population must be > 0");
581        assert!(config.gamma > 0.0, "Recovery rate (gamma) must be > 0");
582
583        let s = config.initial_susceptible();
584        let i = config.initial_infected;
585        let r = config.initial_recovered;
586        let n = config.population;
587
588        let state = SIRState {
589            susceptible: s,
590            infected: i,
591            recovered: r,
592            time: 0.0,
593            rt: config.r0() * (s / n),
594        };
595
596        Self { config, state }
597    }
598
599    /// Execute one step of Gillespie algorithm.
600    ///
601    /// # Errors
602    ///
603    /// Returns an error if simulation encounters invalid state.
604    pub fn step(&mut self, rng: &mut SimRng) -> SimResult<&SIRState> {
605        let n = self.config.population;
606        let beta = self.config.beta;
607        let gamma = self.config.gamma;
608
609        let s = self.state.susceptible;
610        let i = self.state.infected;
611
612        // Propensities (rates)
613        let infection_rate = beta * s * i / n;
614        let recovery_rate = gamma * i;
615        let total_rate = infection_rate + recovery_rate;
616
617        if total_rate <= 0.0 {
618            // No more events possible
619            return Ok(&self.state);
620        }
621
622        // Time to next event (exponential distribution)
623        let u1: f64 = rng.gen_range_f64(0.0, 1.0);
624        let dt = -u1.ln() / total_rate;
625
626        // Choose event type
627        let u2: f64 = rng.gen_range_f64(0.0, 1.0);
628        if u2 < infection_rate / total_rate {
629            // Infection event
630            self.state.susceptible -= 1.0;
631            self.state.infected += 1.0;
632        } else {
633            // Recovery event
634            self.state.infected -= 1.0;
635            self.state.recovered += 1.0;
636        }
637
638        self.state.time += dt;
639        self.state.rt = self.config.r0() * (self.state.susceptible / n);
640
641        Ok(&self.state)
642    }
643
644    /// Run stochastic simulation until epidemic ends.
645    ///
646    /// # Errors
647    ///
648    /// Returns an error if stochastic simulation encounters invalid state.
649    pub fn run(&mut self, max_time: f64, rng: &mut SimRng) -> SimResult<Vec<SIRState>> {
650        let mut trajectory = vec![self.state.clone()];
651
652        while self.state.time < max_time && self.state.infected >= 1.0 {
653            self.step(rng)?;
654            trajectory.push(self.state.clone());
655        }
656
657        Ok(trajectory)
658    }
659
660    /// Get current state.
661    #[must_use]
662    pub const fn state(&self) -> &SIRState {
663        &self.state
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    #[test]
672    fn test_sir_config_default() {
673        let config = SIRConfig::default();
674
675        assert!((config.population - 1_000_000.0).abs() < f64::EPSILON);
676        assert!(config.initial_infected > 0.0);
677    }
678
679    #[test]
680    fn test_sir_config_r0() {
681        let config = SIRConfig::default();
682        let r0 = config.r0();
683
684        // R0 = 0.3 / 0.1 = 3.0
685        assert!((r0 - 3.0).abs() < 0.01, "R0 = {r0}");
686    }
687
688    #[test]
689    fn test_sir_config_herd_immunity() {
690        let config = SIRConfig::default();
691        let hit = config.herd_immunity_threshold();
692
693        // For R0 = 3, HIT = 1 - 1/3 = 0.667
694        assert!((hit - 0.667).abs() < 0.01, "HIT = {hit}");
695    }
696
697    #[test]
698    fn test_sir_scenario_step() {
699        let config = SIRConfig::default();
700        let mut scenario = SIRScenario::new(config);
701
702        let initial_infected = scenario.state().infected;
703        scenario.step(0.1).unwrap();
704
705        // Infected should increase initially (R0 > 1)
706        assert!(
707            scenario.state().infected > initial_infected,
708            "Infected should increase when R0 > 1"
709        );
710    }
711
712    #[test]
713    fn test_sir_scenario_conservation() {
714        let config = SIRConfig::default();
715        let mut scenario = SIRScenario::new(config.clone());
716
717        // Run for a while
718        for _ in 0..100 {
719            scenario.step(0.1).unwrap();
720        }
721
722        // Total population should be conserved
723        let total = scenario.state().total();
724        assert!(
725            (total - config.population).abs() < 1.0,
726            "Population not conserved: {total} != {}",
727            config.population
728        );
729    }
730
731    #[test]
732    fn test_sir_scenario_run() {
733        let config = SIRConfig::default();
734        let mut scenario = SIRScenario::new(config);
735
736        let trajectory = scenario.run(0.1, 200.0).unwrap();
737
738        // Trajectory should have multiple points
739        assert!(trajectory.len() > 10);
740
741        // Should reach near-equilibrium
742        let final_state = trajectory.last().unwrap();
743        assert!(final_state.infected < 100.0, "Epidemic should end");
744    }
745
746    #[test]
747    fn test_sir_peak_infected() {
748        let config = SIRConfig::default();
749        let scenario = SIRScenario::new(config.clone());
750
751        let analytical_peak = scenario.peak_infected();
752
753        // Run simulation to find actual peak
754        let mut sim = SIRScenario::new(config);
755        let trajectory = sim.run(0.1, 200.0).unwrap();
756        let numerical_peak = trajectory.iter().map(|s| s.infected).fold(0.0, f64::max);
757
758        // Analytical and numerical should be close
759        let relative_error = (analytical_peak - numerical_peak).abs() / numerical_peak;
760        assert!(
761            relative_error < 0.05,
762            "Analytical peak {analytical_peak} vs numerical {numerical_peak}"
763        );
764    }
765
766    #[test]
767    fn test_sir_final_size() {
768        let config = SIRConfig::default();
769        let scenario = SIRScenario::new(config.clone());
770
771        let analytical_final = scenario.final_size();
772
773        // Run simulation to get actual final size
774        let mut sim = SIRScenario::new(config);
775        let trajectory = sim.run(0.1, 500.0).unwrap();
776        let numerical_final = trajectory.last().unwrap().recovered;
777
778        // Should be close
779        let relative_error = (analytical_final - numerical_final).abs() / numerical_final;
780        assert!(
781            relative_error < 0.05,
782            "Analytical final size {analytical_final} vs numerical {numerical_final}"
783        );
784    }
785
786    #[test]
787    fn test_seir_scenario_step() {
788        let config = SEIRConfig::default();
789        let mut scenario = SEIRScenario::new(config);
790
791        scenario.step(0.1).unwrap();
792
793        // All compartments should be non-negative
794        assert!(scenario.state().sir.susceptible >= 0.0);
795        assert!(scenario.state().exposed >= 0.0);
796        assert!(scenario.state().sir.infected >= 0.0);
797        assert!(scenario.state().sir.recovered >= 0.0);
798    }
799
800    #[test]
801    fn test_seir_scenario_run() {
802        let config = SEIRConfig::default();
803        let mut scenario = SEIRScenario::new(config);
804
805        let trajectory = scenario.run(0.1, 200.0).unwrap();
806
807        assert!(trajectory.len() > 10);
808    }
809
810    #[test]
811    fn test_seir_delayed_peak() {
812        // SEIR should have delayed peak compared to SIR due to incubation
813        let sir_config = SIRConfig::default();
814        let mut sir = SIRScenario::new(sir_config);
815        let sir_trajectory = sir.run(0.1, 200.0).unwrap();
816
817        let seir_config = SEIRConfig::default();
818        let mut seir = SEIRScenario::new(seir_config);
819        let seir_trajectory = seir.run(0.1, 200.0).unwrap();
820
821        // Find peak times
822        let sir_peak_time = sir_trajectory
823            .iter()
824            .max_by(|a, b| a.infected.partial_cmp(&b.infected).unwrap())
825            .map(|s| s.time)
826            .unwrap();
827
828        let seir_peak_time = seir_trajectory
829            .iter()
830            .max_by(|a, b| a.sir.infected.partial_cmp(&b.sir.infected).unwrap())
831            .map(|s| s.sir.time)
832            .unwrap();
833
834        // SEIR peak should be later
835        assert!(
836            seir_peak_time > sir_peak_time,
837            "SEIR peak time {seir_peak_time} should be > SIR peak time {sir_peak_time}"
838        );
839    }
840
841    #[test]
842    fn test_stochastic_sir_step() {
843        let config = SIRConfig {
844            population: 1000.0,
845            initial_infected: 10.0,
846            ..Default::default()
847        };
848        let mut scenario = StochasticSIR::new(config);
849        let mut rng = SimRng::new(42);
850
851        scenario.step(&mut rng).unwrap();
852
853        // State should change by exactly 1 in one compartment
854        let total = scenario.state().total();
855        assert!((total - 1000.0).abs() < f64::EPSILON);
856    }
857
858    #[test]
859    fn test_stochastic_sir_run() {
860        let config = SIRConfig {
861            population: 1000.0,
862            initial_infected: 10.0,
863            ..Default::default()
864        };
865        let mut scenario = StochasticSIR::new(config);
866        let mut rng = SimRng::new(42);
867
868        let trajectory = scenario.run(500.0, &mut rng).unwrap();
869
870        // Trajectory should have many events
871        assert!(trajectory.len() > 100);
872
873        // Final state should have no infected
874        let final_state = trajectory.last().unwrap();
875        assert!(final_state.infected < 1.0);
876    }
877
878    #[test]
879    fn test_sir_flu() {
880        let config = SIRConfig::flu();
881
882        // Flu has lower R0
883        assert!(config.r0() < 2.0);
884    }
885
886    #[test]
887    fn test_sir_measles() {
888        let config = SIRConfig::measles();
889
890        // Measles is highly contagious
891        assert!(config.r0() > 10.0);
892
893        // Requires high herd immunity threshold
894        assert!(config.herd_immunity_threshold() > 0.9);
895    }
896
897    #[test]
898    fn test_sir_state_is_over() {
899        let state = SIRState {
900            susceptible: 100.0,
901            infected: 0.5,
902            recovered: 899.5,
903            time: 100.0,
904            rt: 0.1,
905        };
906
907        assert!(state.is_over());
908    }
909
910    #[test]
911    fn test_sir_state_infection_rate() {
912        let state = SIRState {
913            susceptible: 500.0,
914            infected: 100.0,
915            recovered: 400.0,
916            time: 10.0,
917            rt: 1.5,
918        };
919
920        assert!((state.infection_rate() - 0.1).abs() < 0.01);
921        assert!((state.susceptible_rate() - 0.5).abs() < 0.01);
922    }
923}
924
925#[cfg(test)]
926mod proptests {
927    use super::*;
928    use proptest::prelude::*;
929
930    proptest! {
931        /// SIR total population is conserved.
932        #[test]
933        fn prop_sir_population_conserved(
934            susceptible in 100.0f64..900.0,
935            infected in 1.0f64..100.0,
936        ) {
937            let recovered = 1000.0 - susceptible - infected;
938            let state = SIRState {
939                susceptible,
940                infected,
941                recovered,
942                time: 0.0,
943                rt: 2.0,
944            };
945
946            let total = state.total();
947            prop_assert!((total - 1000.0).abs() < 0.001, "total={}", total);
948        }
949
950        /// R0 > 1 means epidemic grows initially.
951        #[test]
952        fn prop_r0_greater_than_one_grows(
953            beta in 0.3f64..0.8,
954            gamma in 0.05f64..0.15,
955        ) {
956            let r0 = beta / gamma;
957            // When S/N ≈ 1 and R0 > 1, infections should increase initially
958            if r0 > 1.0 {
959                // dI/dt = β*S*I/N - γ*I = I*(β*S/N - γ)
960                // With S ≈ N: dI/dt ≈ I*(β - γ) = I*γ*(R0 - 1) > 0
961                let growth_rate = gamma * (r0 - 1.0);
962                prop_assert!(growth_rate > 0.0);
963            }
964        }
965
966        /// SEIR total population is conserved.
967        #[test]
968        fn prop_seir_population_conserved(
969            susceptible in 100.0f64..800.0,
970            exposed in 1.0f64..50.0,
971            infected in 1.0f64..50.0,
972        ) {
973            let recovered = 1000.0 - susceptible - exposed - infected;
974            let sir = SIRState {
975                susceptible,
976                infected,
977                recovered,
978                time: 0.0,
979                rt: 2.0,
980            };
981            let state = SEIRState { sir, exposed };
982
983            let total = state.total();
984            prop_assert!((total - 1000.0).abs() < 0.001, "total={}", total);
985        }
986
987        /// Infection rate is non-negative.
988        #[test]
989        fn prop_infection_rate_nonnegative(
990            infected in 0.0f64..500.0,
991            total in 500.0f64..2000.0,
992        ) {
993            let susceptible = total - infected;
994            let state = SIRState {
995                susceptible,
996                infected,
997                recovered: 0.0,
998                time: 0.0,
999                rt: 2.0,
1000            };
1001
1002            let rate = state.infection_rate();
1003            prop_assert!(rate >= 0.0, "rate={}", rate);
1004            prop_assert!(rate <= 1.0, "rate={} should be <= 1", rate);
1005        }
1006
1007        /// Epidemic is over when infected < threshold.
1008        #[test]
1009        fn prop_epidemic_over_threshold(
1010            infected in 0.0f64..10.0,
1011        ) {
1012            let state = SIRState {
1013                susceptible: 100.0,
1014                infected,
1015                recovered: 900.0 - infected,
1016                time: 100.0,
1017                rt: 0.5,
1018            };
1019
1020            // Epidemic is over when infected < 1
1021            if infected < 1.0 {
1022                prop_assert!(state.is_over());
1023            }
1024        }
1025    }
1026}