Skip to main content

proof_engine/ecology/
population.rs

1//! Population dynamics models.
2
3/// Logistic growth: dN/dt = rN(1 - N/K).
4pub fn logistic_growth(pop: f64, rate: f64, capacity: f64, dt: f64) -> f64 {
5    (pop + rate * pop * (1.0 - pop / capacity.max(1.0)) * dt).max(0.0)
6}
7
8/// Lotka-Volterra predator-prey step.
9/// Returns (new_prey, new_predator).
10pub fn lotka_volterra_step(
11    prey: f64, predator: f64,
12    prey_growth: f64, predation_rate: f64,
13    predator_death: f64, conversion_rate: f64,
14    dt: f64,
15) -> (f64, f64) {
16    let dprey = (prey_growth * prey - predation_rate * prey * predator) * dt;
17    let dpred = (conversion_rate * prey * predator - predator_death * predator) * dt;
18    ((prey + dprey).max(0.0), (predator + dpred).max(0.0))
19}
20
21/// Lotka-Volterra competition between two species.
22/// Returns (new_pop1, new_pop2).
23pub fn competition_step(
24    n1: f64, n2: f64,
25    r1: f64, r2: f64,
26    k1: f64, k2: f64,
27    alpha12: f64, alpha21: f64,
28    dt: f64,
29) -> (f64, f64) {
30    let dn1 = r1 * n1 * (1.0 - (n1 + alpha12 * n2) / k1) * dt;
31    let dn2 = r2 * n2 * (1.0 - (n2 + alpha21 * n1) / k2) * dt;
32    ((n1 + dn1).max(0.0), (n2 + dn2).max(0.0))
33}
34
35/// Allee effect: population growth rate decreases at low densities.
36pub fn allee_growth(pop: f64, rate: f64, capacity: f64, allee_threshold: f64, dt: f64) -> f64 {
37    let growth = rate * pop * (pop / allee_threshold - 1.0) * (1.0 - pop / capacity);
38    (pop + growth * dt).max(0.0)
39}
40
41/// Beverton-Holt discrete recruitment model.
42pub fn beverton_holt(pop: f64, r: f64, k: f64) -> f64 {
43    r * pop / (1.0 + (r - 1.0) * pop / k)
44}
45
46/// Ricker model (discrete, density-dependent, can produce chaos).
47pub fn ricker(pop: f64, r: f64, k: f64) -> f64 {
48    pop * (r * (1.0 - pop / k)).exp()
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn test_logistic_approaches_capacity() {
57        let mut pop = 10.0;
58        for _ in 0..1000 {
59            pop = logistic_growth(pop, 0.5, 100.0, 0.1);
60        }
61        assert!((pop - 100.0).abs() < 1.0, "should approach K=100: got {pop}");
62    }
63
64    #[test]
65    fn test_lotka_volterra_oscillation() {
66        let (mut prey, mut pred) = (100.0, 20.0);
67        let mut max_prey = 0.0_f64;
68        let mut min_prey = f64::MAX;
69        for _ in 0..10000 {
70            let (np, nd) = lotka_volterra_step(prey, pred, 0.5, 0.01, 0.3, 0.005, 0.01);
71            prey = np; pred = nd;
72            max_prey = max_prey.max(prey);
73            min_prey = min_prey.min(prey);
74        }
75        assert!(max_prey > min_prey * 1.5, "should oscillate");
76    }
77
78    #[test]
79    fn test_competition_coexistence() {
80        let (mut n1, mut n2) = (50.0, 50.0);
81        for _ in 0..10000 {
82            let (a, b) = competition_step(n1, n2, 0.3, 0.3, 200.0, 200.0, 0.5, 0.5, 0.1);
83            n1 = a; n2 = b;
84        }
85        assert!(n1 > 1.0 && n2 > 1.0, "coexistence with weak competition");
86    }
87
88    #[test]
89    fn test_ricker_bounded() {
90        let mut pop = 10.0;
91        for _ in 0..100 {
92            pop = ricker(pop, 2.0, 100.0);
93            assert!(pop >= 0.0 && pop < 1000.0, "Ricker should stay bounded: {pop}");
94        }
95    }
96}