Skip to main content

simular/scenarios/
climate.rs

1//! Simplified climate model scenarios.
2//!
3//! Implements energy balance climate models:
4//! - Zero-dimensional energy balance model (global mean temperature)
5//! - Climate sensitivity analysis
6//! - Radiative forcing scenarios (CO2, aerosols)
7
8use crate::engine::state::SimState;
9use crate::error::{SimError, SimResult};
10use serde::{Deserialize, Serialize};
11
12/// Stefan-Boltzmann constant (W/m²/K⁴).
13pub const STEFAN_BOLTZMANN: f64 = 5.670_374_419e-8;
14
15/// Solar constant (W/m²).
16pub const SOLAR_CONSTANT: f64 = 1361.0;
17
18/// Pre-industrial CO2 concentration (ppm).
19pub const PREINDUSTRIAL_CO2: f64 = 280.0;
20
21/// Climate sensitivity parameter (K per W/m²).
22/// Typical range: 0.3-1.2 K/(W/m²).
23pub const DEFAULT_CLIMATE_SENSITIVITY: f64 = 0.8;
24
25/// Configuration for energy balance climate model.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ClimateConfig {
28    /// Initial global mean temperature (K).
29    pub initial_temperature: f64,
30    /// Planetary albedo (fraction reflected, 0-1).
31    pub albedo: f64,
32    /// Effective emissivity (greenhouse effect, 0-1).
33    pub emissivity: f64,
34    /// Ocean heat capacity (J/m²/K).
35    pub heat_capacity: f64,
36    /// CO2 concentration (ppm).
37    pub co2_concentration: f64,
38    /// Climate sensitivity parameter (K per W/m²).
39    pub climate_sensitivity: f64,
40    /// Aerosol forcing (W/m², typically negative).
41    pub aerosol_forcing: f64,
42}
43
44impl Default for ClimateConfig {
45    fn default() -> Self {
46        Self {
47            initial_temperature: 288.0, // ~15°C global mean
48            albedo: 0.3,
49            emissivity: 0.612,    // Effective emissivity for Earth
50            heat_capacity: 1.7e8, // Mixed-layer ocean (~50m depth)
51            co2_concentration: PREINDUSTRIAL_CO2,
52            climate_sensitivity: DEFAULT_CLIMATE_SENSITIVITY,
53            aerosol_forcing: 0.0,
54        }
55    }
56}
57
58impl ClimateConfig {
59    /// Create present-day configuration (~420 ppm CO2).
60    #[must_use]
61    pub fn present_day() -> Self {
62        Self {
63            co2_concentration: 420.0,
64            aerosol_forcing: -0.5, // Cooling effect of aerosols
65            ..Default::default()
66        }
67    }
68
69    /// Create doubled CO2 scenario.
70    #[must_use]
71    pub fn doubled_co2() -> Self {
72        Self {
73            co2_concentration: 2.0 * PREINDUSTRIAL_CO2,
74            ..Default::default()
75        }
76    }
77
78    /// Create RCP 8.5 end-of-century scenario (~1000 ppm CO2).
79    #[must_use]
80    pub fn rcp85() -> Self {
81        Self {
82            co2_concentration: 1000.0,
83            ..Default::default()
84        }
85    }
86
87    /// Calculate radiative forcing from CO2 (W/m²).
88    /// Uses logarithmic relationship: ΔF = 5.35 × ln(C/C₀)
89    #[must_use]
90    pub fn co2_forcing(&self) -> f64 {
91        5.35 * (self.co2_concentration / PREINDUSTRIAL_CO2).ln()
92    }
93
94    /// Calculate total radiative forcing (W/m²).
95    #[must_use]
96    pub fn total_forcing(&self) -> f64 {
97        self.co2_forcing() + self.aerosol_forcing
98    }
99
100    /// Calculate equilibrium temperature for current forcing.
101    #[must_use]
102    pub fn equilibrium_temperature(&self) -> f64 {
103        let absorbed_solar = SOLAR_CONSTANT * (1.0 - self.albedo) / 4.0;
104        let forcing = self.total_forcing();
105
106        // Solve: absorbed_solar + forcing = emissivity * sigma * T^4
107        let total_flux = absorbed_solar + forcing;
108        (total_flux / (self.emissivity * STEFAN_BOLTZMANN)).powf(0.25)
109    }
110
111    /// Calculate equilibrium climate sensitivity (ECS) in Kelvin.
112    /// ECS = temperature change for doubled CO2.
113    #[must_use]
114    pub fn ecs(&self) -> f64 {
115        let forcing_2xco2 = 5.35 * 2.0_f64.ln(); // ~3.7 W/m²
116        self.climate_sensitivity * forcing_2xco2
117    }
118}
119
120/// Climate state at a point in time.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ClimateState {
123    /// Global mean surface temperature (K).
124    pub temperature: f64,
125    /// Ocean heat content anomaly (J/m²).
126    pub ocean_heat: f64,
127    /// Radiative imbalance at TOA (W/m²).
128    pub radiative_imbalance: f64,
129    /// Time (years from start).
130    pub time: f64,
131}
132
133/// Energy balance climate model scenario.
134#[derive(Debug, Clone)]
135pub struct ClimateScenario {
136    config: ClimateConfig,
137    state: ClimateState,
138}
139
140impl ClimateScenario {
141    /// Create a new climate scenario.
142    #[must_use]
143    pub fn new(config: ClimateConfig) -> Self {
144        let state = ClimateState {
145            temperature: config.initial_temperature,
146            ocean_heat: 0.0,
147            radiative_imbalance: 0.0,
148            time: 0.0,
149        };
150        Self { config, state }
151    }
152
153    /// Initialize simulation state.
154    #[must_use]
155    pub fn init_state(&self) -> SimState {
156        let mut state = SimState::new();
157        // Use mass=heat_capacity, position.x=temperature, velocity.x=dT/dt
158        state.add_body(
159            self.config.heat_capacity,
160            crate::engine::state::Vec3::new(self.config.initial_temperature, 0.0, 0.0),
161            crate::engine::state::Vec3::zero(),
162        );
163        state
164    }
165
166    /// Step the climate model forward in time.
167    ///
168    /// Uses forward Euler integration of the energy balance equation:
169    /// `C × dT/dt = absorbed_solar + forcing - outgoing_longwave`
170    ///
171    /// # Errors
172    ///
173    /// Returns an error if temperature becomes non-physical (<0K or >500K).
174    #[allow(clippy::while_float)]
175    pub fn step(&mut self, dt_years: f64) -> SimResult<&ClimateState> {
176        // Incoming absorbed solar radiation (W/m²)
177        let absorbed_solar = SOLAR_CONSTANT * (1.0 - self.config.albedo) / 4.0;
178
179        // Radiative forcing
180        let forcing = self.config.total_forcing();
181
182        // Outgoing longwave radiation (W/m²)
183        let outgoing = self.config.emissivity * STEFAN_BOLTZMANN * self.state.temperature.powi(4);
184
185        // Net radiative imbalance
186        let imbalance = absorbed_solar + forcing - outgoing;
187        self.state.radiative_imbalance = imbalance;
188
189        // Temperature change (convert years to seconds)
190        let dt_seconds = dt_years * 365.25 * 24.0 * 3600.0;
191        let dt_temp = imbalance * dt_seconds / self.config.heat_capacity;
192
193        // Jidoka: Check for non-physical temperature
194        let new_temp = self.state.temperature + dt_temp;
195        if !(0.0..=500.0).contains(&new_temp) {
196            return Err(SimError::jidoka(format!(
197                "Non-physical temperature: {new_temp} K"
198            )));
199        }
200
201        self.state.temperature = new_temp;
202        self.state.ocean_heat += imbalance * dt_seconds;
203        self.state.time += dt_years;
204
205        Ok(&self.state)
206    }
207
208    /// Run simulation to equilibrium.
209    ///
210    /// Returns the trajectory of climate states.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if temperature becomes non-physical during simulation.
215    #[allow(clippy::while_float)]
216    pub fn run_to_equilibrium(
217        &mut self,
218        dt_years: f64,
219        max_years: f64,
220        tolerance: f64,
221    ) -> SimResult<Vec<ClimateState>> {
222        let mut trajectory = vec![self.state.clone()];
223
224        while self.state.time < max_years {
225            let prev_temp = self.state.temperature;
226            self.step(dt_years)?;
227            trajectory.push(self.state.clone());
228
229            // Check for equilibrium
230            let temp_change = (self.state.temperature - prev_temp).abs();
231            if temp_change / dt_years < tolerance {
232                break;
233            }
234        }
235
236        Ok(trajectory)
237    }
238
239    /// Get current climate state.
240    #[must_use]
241    pub const fn state(&self) -> &ClimateState {
242        &self.state
243    }
244
245    /// Get configuration.
246    #[must_use]
247    pub const fn config(&self) -> &ClimateConfig {
248        &self.config
249    }
250
251    /// Calculate temperature anomaly from pre-industrial.
252    #[must_use]
253    pub fn temperature_anomaly(&self) -> f64 {
254        self.state.temperature - 288.0 // Pre-industrial baseline
255    }
256
257    /// Set CO2 concentration (for scenario analysis).
258    pub fn set_co2(&mut self, ppm: f64) {
259        self.config.co2_concentration = ppm;
260    }
261}
262
263/// Climate forcing scenario for transient simulations.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ForcingScenario {
266    /// Name of scenario.
267    pub name: String,
268    /// CO2 trajectory (year, ppm).
269    pub co2_trajectory: Vec<(f64, f64)>,
270    /// Aerosol forcing trajectory (year, W/m²).
271    pub aerosol_trajectory: Vec<(f64, f64)>,
272}
273
274impl ForcingScenario {
275    /// Create historical forcing scenario (1850-2020).
276    #[must_use]
277    pub fn historical() -> Self {
278        Self {
279            name: "Historical".to_string(),
280            co2_trajectory: vec![
281                (1850.0, 285.0),
282                (1900.0, 296.0),
283                (1950.0, 311.0),
284                (1980.0, 338.0),
285                (2000.0, 369.0),
286                (2020.0, 414.0),
287            ],
288            aerosol_trajectory: vec![
289                (1850.0, 0.0),
290                (1950.0, -0.2),
291                (1980.0, -0.5),
292                (2000.0, -0.4),
293                (2020.0, -0.3),
294            ],
295        }
296    }
297
298    /// Interpolate CO2 at given year.
299    #[must_use]
300    pub fn co2_at(&self, year: f64) -> f64 {
301        interpolate(&self.co2_trajectory, year)
302    }
303
304    /// Interpolate aerosol forcing at given year.
305    #[must_use]
306    pub fn aerosol_at(&self, year: f64) -> f64 {
307        interpolate(&self.aerosol_trajectory, year)
308    }
309}
310
311/// Linear interpolation helper.
312fn interpolate(data: &[(f64, f64)], x: f64) -> f64 {
313    if data.is_empty() {
314        return 0.0;
315    }
316    if x <= data[0].0 {
317        return data[0].1;
318    }
319    if x >= data[data.len() - 1].0 {
320        return data[data.len() - 1].1;
321    }
322
323    for i in 0..data.len() - 1 {
324        if x >= data[i].0 && x <= data[i + 1].0 {
325            let t = (x - data[i].0) / (data[i + 1].0 - data[i].0);
326            return data[i].1 + t * (data[i + 1].1 - data[i].1);
327        }
328    }
329
330    data[data.len() - 1].1
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn test_climate_config_default() {
339        let config = ClimateConfig::default();
340
341        assert!((config.initial_temperature - 288.0).abs() < 0.01);
342        assert!((config.albedo - 0.3).abs() < 0.01);
343        assert!((config.co2_concentration - PREINDUSTRIAL_CO2).abs() < 0.01);
344    }
345
346    #[test]
347    fn test_climate_config_co2_forcing() {
348        let config = ClimateConfig::default();
349
350        // Pre-industrial: zero forcing
351        assert!(config.co2_forcing().abs() < 0.01);
352
353        // Doubled CO2: ~3.7 W/m²
354        let doubled = ClimateConfig::doubled_co2();
355        let forcing = doubled.co2_forcing();
356        assert!((forcing - 3.7).abs() < 0.1, "2xCO2 forcing = {forcing}");
357    }
358
359    #[test]
360    fn test_climate_config_ecs() {
361        let config = ClimateConfig {
362            climate_sensitivity: 0.8, // K per W/m²
363            ..Default::default()
364        };
365
366        // ECS should be ~3K for typical sensitivity
367        let ecs = config.ecs();
368        assert!(ecs > 2.0 && ecs < 4.0, "ECS = {ecs}");
369    }
370
371    #[test]
372    fn test_climate_scenario_step() {
373        let config = ClimateConfig::doubled_co2();
374        let mut scenario = ClimateScenario::new(config);
375
376        // Initial state
377        assert!((scenario.state().temperature - 288.0).abs() < 0.01);
378
379        // Step forward 1 year
380        scenario.step(1.0).unwrap();
381
382        // Temperature should increase due to positive forcing
383        assert!(scenario.state().temperature > 288.0);
384    }
385
386    #[test]
387    fn test_climate_scenario_equilibrium() {
388        let config = ClimateConfig::doubled_co2();
389        let mut scenario = ClimateScenario::new(config);
390
391        // Run to equilibrium
392        let trajectory = scenario.run_to_equilibrium(1.0, 500.0, 0.001).unwrap();
393
394        // Should reach equilibrium
395        assert!(!trajectory.is_empty());
396
397        // Final temperature should be higher than initial
398        let final_temp = trajectory.last().unwrap().temperature;
399        assert!(final_temp > 288.0, "Final temp = {final_temp}");
400
401        // Temperature increase should be positive for doubled CO2
402        // The exact value depends on model parameters and feedbacks
403        let delta_t = final_temp - 288.0;
404        assert!(delta_t > 0.5, "ΔT = {delta_t} should be positive");
405    }
406
407    #[test]
408    fn test_climate_scenario_present_day() {
409        let config = ClimateConfig::present_day();
410
411        // Present day should have positive CO2 forcing
412        assert!(config.co2_forcing() > 0.0);
413
414        // But aerosol cooling partially offsets
415        assert!(config.aerosol_forcing < 0.0);
416
417        // Net forcing should still be positive
418        assert!(config.total_forcing() > 0.0);
419    }
420
421    #[test]
422    fn test_climate_scenario_rcp85() {
423        let config = ClimateConfig::rcp85();
424
425        // RCP8.5 has much higher CO2 forcing
426        let forcing = config.co2_forcing();
427        assert!(forcing > 6.0, "RCP8.5 forcing = {forcing}");
428    }
429
430    #[test]
431    fn test_forcing_scenario_historical() {
432        let scenario = ForcingScenario::historical();
433
434        // 1850: near pre-industrial
435        let co2_1850 = scenario.co2_at(1850.0);
436        assert!((co2_1850 - 285.0).abs() < 1.0);
437
438        // 2020: ~414 ppm
439        let co2_2020 = scenario.co2_at(2020.0);
440        assert!((co2_2020 - 414.0).abs() < 1.0);
441
442        // Interpolation: 1975 should be between 1950 and 1980
443        let co2_1975 = scenario.co2_at(1975.0);
444        assert!(co2_1975 > 311.0 && co2_1975 < 338.0);
445    }
446
447    #[test]
448    fn test_interpolate() {
449        let data = vec![(0.0, 0.0), (10.0, 100.0)];
450
451        assert!((interpolate(&data, 0.0) - 0.0).abs() < 0.01);
452        assert!((interpolate(&data, 5.0) - 50.0).abs() < 0.01);
453        assert!((interpolate(&data, 10.0) - 100.0).abs() < 0.01);
454
455        // Extrapolation: clamp to endpoints
456        assert!((interpolate(&data, -5.0) - 0.0).abs() < 0.01);
457        assert!((interpolate(&data, 15.0) - 100.0).abs() < 0.01);
458    }
459
460    #[test]
461    fn test_climate_jidoka_temperature_bounds() {
462        let mut config = ClimateConfig::default();
463        config.initial_temperature = 10.0; // Unrealistically cold
464        config.co2_concentration = 10.0; // Very low CO2
465
466        let mut scenario = ClimateScenario::new(config);
467
468        // Should eventually trigger Jidoka if temperature goes non-physical
469        // (though this specific case may not, it tests the bounds checking)
470        let result = scenario.step(100.0);
471        // Either succeeds or fails with Jidoka error
472        match result {
473            Ok(state) => assert!(state.temperature > 0.0 && state.temperature < 500.0),
474            Err(e) => assert!(e.to_string().contains("Non-physical")),
475        }
476    }
477
478    #[test]
479    fn test_climate_equilibrium_temperature() {
480        let config = ClimateConfig::default();
481
482        // Pre-industrial equilibrium should be close to 288K
483        let eq_temp = config.equilibrium_temperature();
484        assert!(
485            (eq_temp - 288.0).abs() < 5.0,
486            "Equilibrium temp = {eq_temp}"
487        );
488    }
489
490    // === Additional Coverage Tests ===
491
492    #[test]
493    fn test_climate_scenario_init_state() {
494        let config = ClimateConfig::default();
495        let scenario = ClimateScenario::new(config);
496        let state = scenario.init_state();
497
498        assert_eq!(state.num_bodies(), 1);
499        // Temperature is stored in position.x
500        assert!((state.positions()[0].x - 288.0).abs() < 0.01);
501    }
502
503    #[test]
504    fn test_climate_scenario_temperature_anomaly() {
505        let mut config = ClimateConfig::default();
506        config.initial_temperature = 290.0; // 2K above pre-industrial
507        let scenario = ClimateScenario::new(config);
508
509        let anomaly = scenario.temperature_anomaly();
510        assert!((anomaly - 2.0).abs() < 0.01, "Anomaly = {anomaly}");
511    }
512
513    #[test]
514    fn test_climate_scenario_set_co2() {
515        let config = ClimateConfig::default();
516        let mut scenario = ClimateScenario::new(config);
517
518        scenario.set_co2(560.0);
519        assert!((scenario.config().co2_concentration - 560.0).abs() < 0.01);
520    }
521
522    #[test]
523    fn test_forcing_scenario_aerosol_at() {
524        let scenario = ForcingScenario::historical();
525
526        // 1850: zero aerosol forcing
527        let aerosol_1850 = scenario.aerosol_at(1850.0);
528        assert!((aerosol_1850 - 0.0).abs() < 0.01);
529
530        // 1980: peak aerosol forcing
531        let aerosol_1980 = scenario.aerosol_at(1980.0);
532        assert!((aerosol_1980 - (-0.5)).abs() < 0.01);
533
534        // Interpolation
535        let aerosol_1965 = scenario.aerosol_at(1965.0);
536        assert!(aerosol_1965 < 0.0 && aerosol_1965 > -0.5);
537    }
538
539    #[test]
540    fn test_interpolate_empty() {
541        let data: Vec<(f64, f64)> = vec![];
542        let result = interpolate(&data, 5.0);
543        assert!((result - 0.0).abs() < f64::EPSILON);
544    }
545
546    #[test]
547    fn test_climate_state_clone_and_debug() {
548        let state = ClimateState {
549            temperature: 288.0,
550            ocean_heat: 1000.0,
551            radiative_imbalance: 0.5,
552            time: 10.0,
553        };
554
555        let cloned = state.clone();
556        assert!((cloned.temperature - 288.0).abs() < f64::EPSILON);
557        assert!((cloned.time - 10.0).abs() < f64::EPSILON);
558
559        let debug = format!("{:?}", state);
560        assert!(debug.contains("ClimateState"));
561        assert!(debug.contains("temperature"));
562    }
563
564    #[test]
565    fn test_climate_config_clone_and_debug() {
566        let config = ClimateConfig::default();
567        let cloned = config.clone();
568        assert!((cloned.albedo - config.albedo).abs() < f64::EPSILON);
569
570        let debug = format!("{:?}", config);
571        assert!(debug.contains("ClimateConfig"));
572    }
573
574    #[test]
575    fn test_climate_scenario_clone() {
576        let config = ClimateConfig::doubled_co2();
577        let scenario = ClimateScenario::new(config);
578        let cloned = scenario.clone();
579
580        assert!((cloned.state().temperature - scenario.state().temperature).abs() < f64::EPSILON);
581    }
582
583    #[test]
584    fn test_forcing_scenario_clone_and_debug() {
585        let scenario = ForcingScenario::historical();
586        let cloned = scenario.clone();
587
588        assert_eq!(cloned.name, "Historical");
589        assert_eq!(cloned.co2_trajectory.len(), scenario.co2_trajectory.len());
590
591        let debug = format!("{:?}", scenario);
592        assert!(debug.contains("ForcingScenario"));
593    }
594}
595
596#[cfg(test)]
597mod proptests {
598    use super::*;
599    use proptest::prelude::*;
600
601    proptest! {
602        /// CO2 forcing increases logarithmically with concentration.
603        #[test]
604        fn prop_co2_forcing_increases_with_concentration(
605            co2_1 in 200.0f64..2000.0,
606            co2_2 in 200.0f64..2000.0,
607        ) {
608            let config1 = ClimateConfig { co2_concentration: co2_1, ..Default::default() };
609            let config2 = ClimateConfig { co2_concentration: co2_2, ..Default::default() };
610
611            let forcing1 = config1.co2_forcing();
612            let forcing2 = config2.co2_forcing();
613
614            // Higher CO2 -> higher forcing
615            if co2_1 > co2_2 {
616                prop_assert!(forcing1 > forcing2);
617            } else if co2_1 < co2_2 {
618                prop_assert!(forcing1 < forcing2);
619            }
620        }
621
622        /// Equilibrium temperature increases with forcing.
623        #[test]
624        fn prop_equilibrium_temp_increases_with_co2(
625            co2_1 in 280.0f64..1500.0,
626            co2_2 in 280.0f64..1500.0,
627        ) {
628            let config1 = ClimateConfig { co2_concentration: co2_1, ..Default::default() };
629            let config2 = ClimateConfig { co2_concentration: co2_2, ..Default::default() };
630
631            let temp1 = config1.equilibrium_temperature();
632            let temp2 = config2.equilibrium_temperature();
633
634            // Higher CO2 -> higher equilibrium temperature
635            if co2_1 > co2_2 {
636                prop_assert!(temp1 > temp2 - 0.001, "temp1={}, temp2={}", temp1, temp2);
637            } else if co2_1 < co2_2 {
638                prop_assert!(temp1 < temp2 + 0.001, "temp1={}, temp2={}", temp1, temp2);
639            }
640        }
641
642        /// Albedo reduces absorbed solar radiation.
643        #[test]
644        fn prop_higher_albedo_less_absorbed(
645            albedo1 in 0.1f64..0.9,
646            albedo2 in 0.1f64..0.9,
647        ) {
648            let config1 = ClimateConfig { albedo: albedo1, ..Default::default() };
649            let config2 = ClimateConfig { albedo: albedo2, ..Default::default() };
650
651            // Absorbed = S/4 * (1 - albedo)
652            let absorbed1 = SOLAR_CONSTANT / 4.0 * (1.0 - config1.albedo);
653            let absorbed2 = SOLAR_CONSTANT / 4.0 * (1.0 - config2.albedo);
654
655            // Higher albedo -> less absorbed
656            if albedo1 > albedo2 {
657                prop_assert!(absorbed1 < absorbed2);
658            } else if albedo1 < albedo2 {
659                prop_assert!(absorbed1 > absorbed2);
660            }
661        }
662
663        /// Climate state temperature must be positive (Kelvin).
664        #[test]
665        fn prop_temperature_positive(
666            initial_temp in 200.0f64..350.0,
667        ) {
668            let config = ClimateConfig {
669                initial_temperature: initial_temp,
670                ..Default::default()
671            };
672            let scenario = ClimateScenario::new(config);
673
674            prop_assert!(scenario.state().temperature > 0.0);
675        }
676
677        /// Total forcing is CO2 forcing plus aerosol forcing.
678        #[test]
679        fn prop_total_forcing_additive(
680            co2 in 280.0f64..1000.0,
681            aerosol in -2.0f64..0.0,
682        ) {
683            let config = ClimateConfig {
684                co2_concentration: co2,
685                aerosol_forcing: aerosol,
686                ..Default::default()
687            };
688
689            let co2_forcing = config.co2_forcing();
690            let total = config.total_forcing();
691
692            let expected = co2_forcing + aerosol;
693            prop_assert!((total - expected).abs() < 1e-10, "total={}, expected={}", total, expected);
694        }
695    }
696}