1use crate::engine::state::SimState;
9use crate::error::{SimError, SimResult};
10use serde::{Deserialize, Serialize};
11
12pub const STEFAN_BOLTZMANN: f64 = 5.670_374_419e-8;
14
15pub const SOLAR_CONSTANT: f64 = 1361.0;
17
18pub const PREINDUSTRIAL_CO2: f64 = 280.0;
20
21pub const DEFAULT_CLIMATE_SENSITIVITY: f64 = 0.8;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ClimateConfig {
28 pub initial_temperature: f64,
30 pub albedo: f64,
32 pub emissivity: f64,
34 pub heat_capacity: f64,
36 pub co2_concentration: f64,
38 pub climate_sensitivity: f64,
40 pub aerosol_forcing: f64,
42}
43
44impl Default for ClimateConfig {
45 fn default() -> Self {
46 Self {
47 initial_temperature: 288.0, albedo: 0.3,
49 emissivity: 0.612, heat_capacity: 1.7e8, co2_concentration: PREINDUSTRIAL_CO2,
52 climate_sensitivity: DEFAULT_CLIMATE_SENSITIVITY,
53 aerosol_forcing: 0.0,
54 }
55 }
56}
57
58impl ClimateConfig {
59 #[must_use]
61 pub fn present_day() -> Self {
62 Self {
63 co2_concentration: 420.0,
64 aerosol_forcing: -0.5, ..Default::default()
66 }
67 }
68
69 #[must_use]
71 pub fn doubled_co2() -> Self {
72 Self {
73 co2_concentration: 2.0 * PREINDUSTRIAL_CO2,
74 ..Default::default()
75 }
76 }
77
78 #[must_use]
80 pub fn rcp85() -> Self {
81 Self {
82 co2_concentration: 1000.0,
83 ..Default::default()
84 }
85 }
86
87 #[must_use]
90 pub fn co2_forcing(&self) -> f64 {
91 5.35 * (self.co2_concentration / PREINDUSTRIAL_CO2).ln()
92 }
93
94 #[must_use]
96 pub fn total_forcing(&self) -> f64 {
97 self.co2_forcing() + self.aerosol_forcing
98 }
99
100 #[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 let total_flux = absorbed_solar + forcing;
108 (total_flux / (self.emissivity * STEFAN_BOLTZMANN)).powf(0.25)
109 }
110
111 #[must_use]
114 pub fn ecs(&self) -> f64 {
115 let forcing_2xco2 = 5.35 * 2.0_f64.ln(); self.climate_sensitivity * forcing_2xco2
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ClimateState {
123 pub temperature: f64,
125 pub ocean_heat: f64,
127 pub radiative_imbalance: f64,
129 pub time: f64,
131}
132
133#[derive(Debug, Clone)]
135pub struct ClimateScenario {
136 config: ClimateConfig,
137 state: ClimateState,
138}
139
140impl ClimateScenario {
141 #[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 #[must_use]
155 pub fn init_state(&self) -> SimState {
156 let mut state = SimState::new();
157 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 #[allow(clippy::while_float)]
175 pub fn step(&mut self, dt_years: f64) -> SimResult<&ClimateState> {
176 let absorbed_solar = SOLAR_CONSTANT * (1.0 - self.config.albedo) / 4.0;
178
179 let forcing = self.config.total_forcing();
181
182 let outgoing = self.config.emissivity * STEFAN_BOLTZMANN * self.state.temperature.powi(4);
184
185 let imbalance = absorbed_solar + forcing - outgoing;
187 self.state.radiative_imbalance = imbalance;
188
189 let dt_seconds = dt_years * 365.25 * 24.0 * 3600.0;
191 let dt_temp = imbalance * dt_seconds / self.config.heat_capacity;
192
193 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 #[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 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 #[must_use]
241 pub const fn state(&self) -> &ClimateState {
242 &self.state
243 }
244
245 #[must_use]
247 pub const fn config(&self) -> &ClimateConfig {
248 &self.config
249 }
250
251 #[must_use]
253 pub fn temperature_anomaly(&self) -> f64 {
254 self.state.temperature - 288.0 }
256
257 pub fn set_co2(&mut self, ppm: f64) {
259 self.config.co2_concentration = ppm;
260 }
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ForcingScenario {
266 pub name: String,
268 pub co2_trajectory: Vec<(f64, f64)>,
270 pub aerosol_trajectory: Vec<(f64, f64)>,
272}
273
274impl ForcingScenario {
275 #[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 #[must_use]
300 pub fn co2_at(&self, year: f64) -> f64 {
301 interpolate(&self.co2_trajectory, year)
302 }
303
304 #[must_use]
306 pub fn aerosol_at(&self, year: f64) -> f64 {
307 interpolate(&self.aerosol_trajectory, year)
308 }
309}
310
311fn 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 assert!(config.co2_forcing().abs() < 0.01);
352
353 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, ..Default::default()
364 };
365
366 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 assert!((scenario.state().temperature - 288.0).abs() < 0.01);
378
379 scenario.step(1.0).unwrap();
381
382 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 let trajectory = scenario.run_to_equilibrium(1.0, 500.0, 0.001).unwrap();
393
394 assert!(!trajectory.is_empty());
396
397 let final_temp = trajectory.last().unwrap().temperature;
399 assert!(final_temp > 288.0, "Final temp = {final_temp}");
400
401 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 assert!(config.co2_forcing() > 0.0);
413
414 assert!(config.aerosol_forcing < 0.0);
416
417 assert!(config.total_forcing() > 0.0);
419 }
420
421 #[test]
422 fn test_climate_scenario_rcp85() {
423 let config = ClimateConfig::rcp85();
424
425 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 let co2_1850 = scenario.co2_at(1850.0);
436 assert!((co2_1850 - 285.0).abs() < 1.0);
437
438 let co2_2020 = scenario.co2_at(2020.0);
440 assert!((co2_2020 - 414.0).abs() < 1.0);
441
442 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 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; config.co2_concentration = 10.0; let mut scenario = ClimateScenario::new(config);
467
468 let result = scenario.step(100.0);
471 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 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 #[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 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; 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 let aerosol_1850 = scenario.aerosol_at(1850.0);
528 assert!((aerosol_1850 - 0.0).abs() < 0.01);
529
530 let aerosol_1980 = scenario.aerosol_at(1980.0);
532 assert!((aerosol_1980 - (-0.5)).abs() < 0.01);
533
534 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 #[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 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 #[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 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 #[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 let absorbed1 = SOLAR_CONSTANT / 4.0 * (1.0 - config1.albedo);
653 let absorbed2 = SOLAR_CONSTANT / 4.0 * (1.0 - config2.albedo);
654
655 if albedo1 > albedo2 {
657 prop_assert!(absorbed1 < absorbed2);
658 } else if albedo1 < albedo2 {
659 prop_assert!(absorbed1 > absorbed2);
660 }
661 }
662
663 #[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 #[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}