Skip to main content

proof_engine/worldgen/
climate.rs

1//! Climate simulation — atmospheric circulation driven by heat equation PDE.
2//!
3//! Simulates temperature distribution (latitude + altitude + ocean currents)
4//! and precipitation (orographic rainfall, moisture transport).
5
6use super::{Grid2D, Rng};
7use std::f32::consts::PI;
8
9/// Climate parameters.
10#[derive(Debug, Clone)]
11pub struct ClimateParams {
12    /// Base equatorial temperature (°C).
13    pub equator_temp: f32,
14    /// Polar temperature (°C).
15    pub polar_temp: f32,
16    /// Temperature lapse rate per unit elevation (°C per elevation unit).
17    pub lapse_rate: f32,
18    /// Ocean thermal inertia (moderates coastal temperatures).
19    pub ocean_moderation: f32,
20    /// Base wind direction (0 = east, PI/2 = north).
21    pub prevailing_wind: f32,
22    /// Wind speed.
23    pub wind_speed: f32,
24    /// Moisture pickup rate over ocean.
25    pub moisture_rate: f32,
26    /// Orographic precipitation factor.
27    pub orographic_factor: f32,
28    /// Thermal diffusion rate.
29    pub diffusion: f32,
30    /// Sea level (below this = ocean).
31    pub sea_level: f32,
32}
33
34impl Default for ClimateParams {
35    fn default() -> Self {
36        Self {
37            equator_temp: 30.0,
38            polar_temp: -20.0,
39            lapse_rate: 40.0,
40            ocean_moderation: 0.3,
41            prevailing_wind: 0.0,
42            wind_speed: 1.0,
43            moisture_rate: 0.05,
44            orographic_factor: 3.0,
45            diffusion: 0.1,
46            sea_level: 0.4,
47        }
48    }
49}
50
51/// Simulate climate and return (temperature, precipitation) grids.
52pub fn simulate(heightmap: &Grid2D, iterations: usize, rng: &mut Rng) -> (Grid2D, Grid2D) {
53    let params = ClimateParams::default();
54    simulate_with_params(heightmap, iterations, &params, rng)
55}
56
57/// Simulate with custom parameters.
58pub fn simulate_with_params(
59    heightmap: &Grid2D,
60    iterations: usize,
61    params: &ClimateParams,
62    _rng: &mut Rng,
63) -> (Grid2D, Grid2D) {
64    let w = heightmap.width;
65    let h = heightmap.height;
66
67    // 1. Base temperature from latitude + altitude
68    let mut temperature = Grid2D::new(w, h);
69    for y in 0..h {
70        let latitude = (y as f32 / h as f32 - 0.5).abs() * 2.0; // 0 at equator, 1 at poles
71        let lat_temp = params.equator_temp + (params.polar_temp - params.equator_temp) * latitude;
72
73        for x in 0..w {
74            let elevation = heightmap.get(x, y);
75            let alt_offset = if elevation > params.sea_level {
76                -(elevation - params.sea_level) * params.lapse_rate
77            } else {
78                0.0
79            };
80
81            // Ocean cells moderate toward ocean temp
82            let is_ocean = elevation < params.sea_level;
83            let base = lat_temp + alt_offset;
84            let temp = if is_ocean {
85                base * (1.0 - params.ocean_moderation) + lat_temp * params.ocean_moderation
86            } else {
87                base
88            };
89
90            temperature.set(x, y, temp);
91        }
92    }
93
94    // 2. Thermal diffusion (iterate heat equation)
95    for _ in 0..iterations {
96        diffuse_temperature(&mut temperature, params.diffusion);
97    }
98
99    // 3. Precipitation from moisture transport
100    let mut precipitation = Grid2D::new(w, h);
101
102    // Wind-driven moisture transport
103    let wind_dx = params.prevailing_wind.cos();
104    let wind_dy = params.prevailing_wind.sin();
105
106    // Simulate moisture advection from multiple wind directions (Hadley cells)
107    for wind_band in 0..3 {
108        let band_angle = match wind_band {
109            0 => params.prevailing_wind,           // Trade winds
110            1 => params.prevailing_wind + PI,       // Westerlies
111            _ => params.prevailing_wind + PI * 0.5, // Polar easterlies
112        };
113        let wdx = band_angle.cos() * params.wind_speed;
114        let wdy = band_angle.sin() * params.wind_speed;
115
116        // Band latitude range
117        let (y_min, y_max) = match wind_band {
118            0 => (h / 3, 2 * h / 3),  // Tropics
119            1 => (0, h / 3),           // Mid-latitudes (north)
120            _ => (2 * h / 3, h),       // Mid-latitudes (south)
121        };
122
123        let mut moisture = Grid2D::new(w, h);
124
125        // Advect moisture
126        for step in 0..w.max(h) {
127            for y in y_min..y_max {
128                for x in 0..w {
129                    let sx = (x as f32 - wdx * step as f32).rem_euclid(w as f32) as usize;
130                    let sy = (y as f32 - wdy * step as f32).clamp(0.0, h as f32 - 1.0) as usize;
131
132                    let elev = heightmap.get(x, y);
133                    let is_ocean = elev < params.sea_level;
134
135                    if is_ocean {
136                        // Pick up moisture over ocean
137                        moisture.add(x, y, params.moisture_rate);
138                    } else {
139                        // Orographic precipitation: upslope forces moisture out
140                        let (gx, gy) = heightmap.gradient(x, y);
141                        let upslope = gx * wdx + gy * wdy;
142                        if upslope > 0.0 {
143                            let rain = moisture.get(x, y) * upslope * params.orographic_factor;
144                            let rain = rain.min(moisture.get(x, y));
145                            precipitation.add(x, y, rain);
146                            moisture.add(x, y, -rain);
147                        }
148                    }
149                }
150            }
151        }
152
153        // Base precipitation from remaining moisture
154        for y in y_min..y_max {
155            for x in 0..w {
156                precipitation.add(x, y, moisture.get(x, y) * 0.1);
157            }
158        }
159    }
160
161    // Add latitude-based baseline precipitation (ITCZ at equator)
162    for y in 0..h {
163        let lat = (y as f32 / h as f32 - 0.5).abs() * 2.0;
164        let itcz = (1.0 - lat * 3.0).max(0.0); // High rainfall near equator
165        for x in 0..w {
166            precipitation.add(x, y, itcz * 0.5);
167        }
168    }
169
170    // Normalize precipitation to [0, 1]
171    precipitation.normalize();
172
173    (temperature, precipitation)
174}
175
176/// One step of thermal diffusion (2D heat equation).
177fn diffuse_temperature(temp: &mut Grid2D, rate: f32) {
178    let w = temp.width;
179    let h = temp.height;
180    let old = temp.data.clone();
181
182    for y in 1..h - 1 {
183        for x in 1..w - 1 {
184            let idx = y * w + x;
185            let laplacian = old[idx - 1] + old[idx + 1]
186                + old[idx - w] + old[idx + w]
187                - 4.0 * old[idx];
188            temp.data[idx] += laplacian * rate;
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_temperature_latitude() {
199        let hm = Grid2D::filled(32, 32, 0.5); // flat land
200        let mut rng = Rng::new(42);
201        let (temp, _) = simulate(&hm, 10, &mut rng);
202        // Equator (y=16) should be warmer than poles (y=0 or y=31)
203        let equator_t = temp.get(16, 16);
204        let pole_t = temp.get(16, 0);
205        assert!(equator_t > pole_t, "equator should be warmer: eq={equator_t}, pole={pole_t}");
206    }
207
208    #[test]
209    fn test_precipitation_exists() {
210        let hm = Grid2D::filled(32, 32, 0.3); // mostly ocean
211        let mut rng = Rng::new(42);
212        let (_, precip) = simulate(&hm, 10, &mut rng);
213        let max_p = precip.max_value();
214        assert!(max_p > 0.0, "should have some precipitation");
215    }
216}