Skip to main content

proof_engine/weather/
climate.rs

1//! Climate zone and seasonal cycle subsystem.
2//!
3//! Models biome climate zones, seasonal temperature/precipitation cycles,
4//! day/night temperature curves, climate interpolation, storm fronts,
5//! heat waves, cold snaps, and weather pattern transitions.
6
7use std::collections::HashMap;
8use super::{Vec3, lerp, smoothstep, fbm_2d, value_noise_2d};
9
10// ── Season ────────────────────────────────────────────────────────────────────
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum Season {
14    Spring,
15    Summer,
16    Autumn,
17    Winter,
18}
19
20impl Season {
21    /// Determine season from day-of-year [0,365) and hemisphere.
22    pub fn from_day(day: f32, northern: bool) -> Self {
23        let d = day.rem_euclid(365.0);
24        let raw = if d < 80.0       { Season::Winter }
25            else if d < 172.0       { Season::Spring }
26            else if d < 264.0       { Season::Summer }
27            else if d < 355.0       { Season::Autumn }
28            else                    { Season::Winter };
29        if northern { raw } else { raw.opposite() }
30    }
31
32    pub fn opposite(self) -> Self {
33        match self {
34            Self::Spring => Self::Autumn,
35            Self::Summer => Self::Winter,
36            Self::Autumn => Self::Spring,
37            Self::Winter => Self::Summer,
38        }
39    }
40
41    pub fn name(self) -> &'static str {
42        match self {
43            Self::Spring => "Spring",
44            Self::Summer => "Summer",
45            Self::Autumn => "Autumn",
46            Self::Winter => "Winter",
47        }
48    }
49
50    /// Solar declination factor [-1, 1] for this season (rough).
51    pub fn solar_factor(self) -> f32 {
52        match self {
53            Self::Summer =>  1.0,
54            Self::Winter => -1.0,
55            Self::Spring =>  0.2,
56            Self::Autumn => -0.2,
57        }
58    }
59}
60
61// ── Biome Types ───────────────────────────────────────────────────────────────
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum BiomeType {
65    TropicalRainforest,
66    TropicalSavanna,
67    HotDesert,
68    ColdDesert,
69    MediterraneanShrubland,
70    TemperateGrassland,
71    TemperateDeciduousForest,
72    TemperateConiferousForest,
73    BorealForest,       // Taiga
74    Tundra,
75    PolarIce,
76    MontaneAlpine,
77    CoastalMarine,
78    Wetland,
79    UrbanHeatIsland,
80}
81
82impl BiomeType {
83    /// Mean annual temperature (°C).
84    pub fn mean_annual_temp_c(self) -> f32 {
85        match self {
86            Self::TropicalRainforest      =>  26.0,
87            Self::TropicalSavanna         =>  24.0,
88            Self::HotDesert               =>  25.0,
89            Self::ColdDesert              =>   8.0,
90            Self::MediterraneanShrubland  =>  15.0,
91            Self::TemperateGrassland      =>   8.0,
92            Self::TemperateDeciduousForest =>  10.0,
93            Self::TemperateConiferousForest =>  7.0,
94            Self::BorealForest            =>  -3.0,
95            Self::Tundra                  => -10.0,
96            Self::PolarIce                => -25.0,
97            Self::MontaneAlpine           =>  -2.0,
98            Self::CoastalMarine           =>  14.0,
99            Self::Wetland                 =>  12.0,
100            Self::UrbanHeatIsland         =>  13.0,
101        }
102    }
103
104    /// Annual temperature range (°C) between coldest and warmest month.
105    pub fn annual_temp_range_c(self) -> f32 {
106        match self {
107            Self::TropicalRainforest      =>  2.0,
108            Self::TropicalSavanna         =>  8.0,
109            Self::HotDesert               => 20.0,
110            Self::ColdDesert              => 35.0,
111            Self::MediterraneanShrubland  => 15.0,
112            Self::TemperateGrassland      => 30.0,
113            Self::TemperateDeciduousForest => 25.0,
114            Self::TemperateConiferousForest => 28.0,
115            Self::BorealForest            => 40.0,
116            Self::Tundra                  => 35.0,
117            Self::PolarIce                => 30.0,
118            Self::MontaneAlpine           => 22.0,
119            Self::CoastalMarine           => 10.0,
120            Self::Wetland                 => 20.0,
121            Self::UrbanHeatIsland         => 22.0,
122        }
123    }
124
125    /// Mean annual precipitation (mm).
126    pub fn mean_annual_precip_mm(self) -> f32 {
127        match self {
128            Self::TropicalRainforest      => 2500.0,
129            Self::TropicalSavanna         =>  900.0,
130            Self::HotDesert               =>   50.0,
131            Self::ColdDesert              =>  150.0,
132            Self::MediterraneanShrubland  =>  500.0,
133            Self::TemperateGrassland      =>  400.0,
134            Self::TemperateDeciduousForest => 750.0,
135            Self::TemperateConiferousForest => 900.0,
136            Self::BorealForest            =>  500.0,
137            Self::Tundra                  =>  200.0,
138            Self::PolarIce                =>  100.0,
139            Self::MontaneAlpine           =>  800.0,
140            Self::CoastalMarine           =>  700.0,
141            Self::Wetland                 => 1000.0,
142            Self::UrbanHeatIsland         =>  600.0,
143        }
144    }
145
146    /// Diurnal temperature range (°C, day vs night).
147    pub fn diurnal_range_c(self) -> f32 {
148        match self {
149            Self::TropicalRainforest      =>  5.0,
150            Self::HotDesert               => 25.0,
151            Self::CoastalMarine           =>  6.0,
152            Self::PolarIce                =>  8.0,
153            _                             => 12.0,
154        }
155    }
156}
157
158// ── Climate Zone ──────────────────────────────────────────────────────────────
159
160/// A climate zone associated with a biome.
161#[derive(Debug, Clone)]
162pub struct BiomeZone {
163    pub biome: BiomeType,
164    /// Latitude range [min, max] degrees.
165    pub latitude_range: [f32; 2],
166    /// Altitude range [min, max] metres.
167    pub altitude_range: [f32; 2],
168    /// Koppen climate classification code.
169    pub koppen: &'static str,
170    /// Dominant wind direction (radians from east).
171    pub prevailing_wind_dir: f32,
172    /// Mean wind speed (m/s).
173    pub mean_wind_speed: f32,
174}
175
176impl BiomeZone {
177    pub fn temperate_deciduous() -> Self {
178        Self {
179            biome: BiomeType::TemperateDeciduousForest,
180            latitude_range: [40.0, 60.0],
181            altitude_range: [0.0, 1500.0],
182            koppen: "Cfb",
183            prevailing_wind_dir: 0.0, // westerlies
184            mean_wind_speed: 6.0,
185        }
186    }
187
188    pub fn tropical_rainforest() -> Self {
189        Self {
190            biome: BiomeType::TropicalRainforest,
191            latitude_range: [-10.0, 10.0],
192            altitude_range: [0.0, 1000.0],
193            koppen: "Af",
194            prevailing_wind_dir: std::f32::consts::PI * 0.25,
195            mean_wind_speed: 3.0,
196        }
197    }
198
199    pub fn hot_desert() -> Self {
200        Self {
201            biome: BiomeType::HotDesert,
202            latitude_range: [20.0, 35.0],
203            altitude_range: [0.0, 800.0],
204            koppen: "BWh",
205            prevailing_wind_dir: std::f32::consts::PI * 0.75,
206            mean_wind_speed: 8.0,
207        }
208    }
209
210    pub fn boreal() -> Self {
211        Self {
212            biome: BiomeType::BorealForest,
213            latitude_range: [50.0, 70.0],
214            altitude_range: [0.0, 800.0],
215            koppen: "Dfc",
216            prevailing_wind_dir: 0.0,
217            mean_wind_speed: 5.0,
218        }
219    }
220
221    /// Is the given latitude inside this zone's range?
222    pub fn contains_lat(&self, lat: f32) -> bool {
223        lat >= self.latitude_range[0] && lat <= self.latitude_range[1]
224    }
225
226    /// Is the given altitude inside this zone's range?
227    pub fn contains_alt(&self, alt: f32) -> bool {
228        alt >= self.altitude_range[0] && alt <= self.altitude_range[1]
229    }
230}
231
232// ── Temperature Range ─────────────────────────────────────────────────────────
233
234/// Min/max temperature over a period.
235#[derive(Debug, Clone, Copy)]
236pub struct TemperatureRange {
237    pub min_c: f32,
238    pub max_c: f32,
239}
240
241impl TemperatureRange {
242    pub fn new(min_c: f32, max_c: f32) -> Self { Self { min_c, max_c } }
243    pub fn mean(&self) -> f32 { (self.min_c + self.max_c) * 0.5 }
244    pub fn amplitude(&self) -> f32 { self.max_c - self.min_c }
245    pub fn contains(&self, t: f32) -> bool { t >= self.min_c && t <= self.max_c }
246}
247
248// ── Seasonal Cycle ────────────────────────────────────────────────────────────
249
250/// Seasonal temperature and precipitation data for a location.
251#[derive(Debug, Clone)]
252pub struct SeasonalCycle {
253    pub biome: BiomeType,
254    /// Monthly mean temperatures (°C) — 12 values Jan–Dec.
255    pub monthly_temp_c: [f32; 12],
256    /// Monthly precipitation (mm) — 12 values.
257    pub monthly_precip_mm: [f32; 12],
258    /// Monthly mean humidity [0,1].
259    pub monthly_humidity: [f32; 12],
260    /// Monthly sunshine hours (average per day).
261    pub monthly_sunshine_h: [f32; 12],
262}
263
264impl SeasonalCycle {
265    /// Build a simple sinusoidal cycle from biome parameters.
266    pub fn from_biome(biome: BiomeType, latitude: f32) -> Self {
267        let base_temp = biome.mean_annual_temp_c();
268        let amplitude = biome.annual_temp_range_c() * 0.5;
269        let northern  = latitude >= 0.0;
270        // Northern hemisphere: coldest Jan (month 0), warmest Jul (month 6)
271        let mut monthly_temp_c = [0.0_f32; 12];
272        let mut monthly_precip_mm = [0.0_f32; 12];
273        let mut monthly_humidity = [0.0_f32; 12];
274        let mut monthly_sunshine_h = [0.0_f32; 12];
275
276        let annual_precip = biome.mean_annual_precip_mm();
277        for m in 0..12 {
278            // Temperature: cosine with phase shift
279            let phase = if northern {
280                (m as f32 - 6.5) / 12.0 * 2.0 * std::f32::consts::PI
281            } else {
282                (m as f32 - 0.5) / 12.0 * 2.0 * std::f32::consts::PI
283            };
284            monthly_temp_c[m] = base_temp - amplitude * phase.cos();
285
286            // Precipitation: varies by biome type
287            let precip_phase = match biome {
288                BiomeType::MediterraneanShrubland => {
289                    // Wet winters, dry summers
290                    let p = (m as f32 - 0.5) / 12.0 * 2.0 * std::f32::consts::PI;
291                    1.0 + p.cos() // peak in Jan
292                }
293                BiomeType::TropicalSavanna => {
294                    // Wet summer, dry winter
295                    let p = (m as f32 - 6.5) / 12.0 * 2.0 * std::f32::consts::PI;
296                    1.0 + p.cos()
297                }
298                _ => 1.0, // uniform
299            };
300            monthly_precip_mm[m] = annual_precip / 12.0 * precip_phase;
301
302            // Humidity: inverse of temperature for most biomes
303            let temp_norm = (monthly_temp_c[m] - (base_temp - amplitude))
304                / (2.0 * amplitude).max(1.0);
305            monthly_humidity[m] = match biome {
306                BiomeType::TropicalRainforest => 0.85 + temp_norm * 0.1,
307                BiomeType::HotDesert | BiomeType::ColdDesert => 0.15 + (1.0 - temp_norm) * 0.15,
308                _ => 0.55 + (1.0 - temp_norm) * 0.2,
309            }.clamp(0.1, 1.0);
310
311            // Sunshine: longer days in summer
312            monthly_sunshine_h[m] = 8.0 + (monthly_temp_c[m] - base_temp) / amplitude.max(1.0) * 4.0;
313        }
314
315        Self {
316            biome,
317            monthly_temp_c,
318            monthly_precip_mm,
319            monthly_humidity,
320            monthly_sunshine_h,
321        }
322    }
323
324    /// Interpolated temperature for fractional month (0.0 = Jan 1, 11.999 = Dec 31).
325    pub fn temperature_at_month(&self, month_frac: f32) -> f32 {
326        let m0 = (month_frac.floor() as usize) % 12;
327        let m1 = (m0 + 1) % 12;
328        let t  = month_frac - month_frac.floor();
329        lerp(self.monthly_temp_c[m0], self.monthly_temp_c[m1], t)
330    }
331
332    /// Convert day-of-year [0,365) to fractional month index.
333    pub fn day_to_month_frac(day: f32) -> f32 {
334        (day / 365.0 * 12.0).rem_euclid(12.0)
335    }
336
337    pub fn humidity_at_month(&self, month_frac: f32) -> f32 {
338        let m0 = (month_frac.floor() as usize) % 12;
339        let m1 = (m0 + 1) % 12;
340        let t  = month_frac - month_frac.floor();
341        lerp(self.monthly_humidity[m0], self.monthly_humidity[m1], t)
342    }
343
344    pub fn precipitation_at_month(&self, month_frac: f32) -> f32 {
345        let m0 = (month_frac.floor() as usize) % 12;
346        let m1 = (m0 + 1) % 12;
347        let t  = month_frac - month_frac.floor();
348        lerp(self.monthly_precip_mm[m0], self.monthly_precip_mm[m1], t)
349    }
350}
351
352// ── Day/Night Temperature Curve ───────────────────────────────────────────────
353
354/// Models the diurnal temperature variation throughout a 24-hour cycle.
355#[derive(Debug, Clone)]
356pub struct DayNightCurve {
357    /// Temperature at sunrise (°C).
358    pub sunrise_temp_c: f32,
359    /// Temperature at daily maximum (°C).
360    pub max_temp_c: f32,
361    /// Temperature at sunset (°C).
362    pub sunset_temp_c: f32,
363    /// Temperature at daily minimum (°C), typically before sunrise.
364    pub min_temp_c: f32,
365    /// Hour of sunrise (0–24).
366    pub sunrise_h: f32,
367    /// Hour of maximum temperature (0–24), typically 2–3 h after solar noon.
368    pub max_temp_h: f32,
369    /// Hour of sunset (0–24).
370    pub sunset_h: f32,
371    /// Hour of minimum temperature (0–24), typically just before sunrise.
372    pub min_temp_h: f32,
373}
374
375impl DayNightCurve {
376    pub fn new(
377        mean_c: f32,
378        amplitude_c: f32,
379        day_length_h: f32,
380        solar_noon: f32,
381    ) -> Self {
382        let half_day = day_length_h * 0.5;
383        let sunrise_h = (solar_noon - half_day + 24.0).rem_euclid(24.0);
384        let sunset_h  = (solar_noon + half_day).rem_euclid(24.0);
385        let max_temp_h = (solar_noon + 2.5).rem_euclid(24.0);
386        let min_temp_h = (sunrise_h - 1.5 + 24.0).rem_euclid(24.0);
387        Self {
388            sunrise_temp_c: mean_c - amplitude_c * 0.6,
389            max_temp_c:     mean_c + amplitude_c * 0.5,
390            sunset_temp_c:  mean_c - amplitude_c * 0.2,
391            min_temp_c:     mean_c - amplitude_c * 0.5,
392            sunrise_h,
393            max_temp_h,
394            sunset_h,
395            min_temp_h,
396        }
397    }
398
399    /// Sample temperature (°C) at time of day `h` (0–24).
400    pub fn temperature_at(&self, h: f32) -> f32 {
401        // Use piecewise cosine interpolation between key points
402        let h = h.rem_euclid(24.0);
403        // Key points in order: min, sunrise, max, sunset, (back to min next day)
404        struct Kp { h: f32, t: f32 }
405        let mut kps = [
406            Kp { h: self.min_temp_h,    t: self.min_temp_c    },
407            Kp { h: self.sunrise_h,     t: self.sunrise_temp_c},
408            Kp { h: self.max_temp_h,    t: self.max_temp_c    },
409            Kp { h: self.sunset_h,      t: self.sunset_temp_c },
410        ];
411        // Sort by hour for consistent interpolation
412        kps.sort_by(|a, b| a.h.partial_cmp(&b.h).unwrap_or(std::cmp::Ordering::Equal));
413
414        // Find bracketing points
415        for i in 0..kps.len() {
416            let next = (i + 1) % kps.len();
417            let h0 = kps[i].h;
418            let h1 = if next == 0 { kps[next].h + 24.0 } else { kps[next].h };
419            let hh  = if h < h0 { h + 24.0 } else { h };
420            if hh >= h0 && hh <= h1 {
421                let span = (h1 - h0).max(1e-4);
422                let t    = (hh - h0) / span;
423                // Cosine interpolation
424                let tc = (1.0 - (t * std::f32::consts::PI).cos()) * 0.5;
425                return lerp(kps[i].t, kps[next].t, tc);
426            }
427        }
428        self.min_temp_c
429    }
430
431    /// Compute day-length hours from latitude and day-of-year.
432    pub fn day_length_from_lat(lat_deg: f32, day: f32) -> f32 {
433        let lat_rad = lat_deg.to_radians();
434        // Solar declination
435        let dec = (-23.45_f32 * ((day + 10.0) / 365.0 * 2.0 * std::f32::consts::PI).cos()).to_radians();
436        let cos_ha = -(lat_rad.tan() * dec.tan());
437        if cos_ha < -1.0 { return 24.0; }
438        if cos_ha >  1.0 { return 0.0; }
439        let ha_rad = cos_ha.acos();
440        ha_rad.to_degrees() / 7.5 // hours = degrees / 15 * 2 (for full arc)
441    }
442}
443
444// ── Climate Interpolator ──────────────────────────────────────────────────────
445
446/// Blends between two climate zones based on a parameter.
447#[derive(Debug, Clone)]
448pub struct ClimateInterpolator {
449    pub zone_a: BiomeZone,
450    pub zone_b: BiomeZone,
451    pub cycle_a: SeasonalCycle,
452    pub cycle_b: SeasonalCycle,
453    /// Blend factor [0,1]; 0 = pure A, 1 = pure B.
454    pub blend: f32,
455    /// Target blend (for smooth transitions).
456    pub target_blend: f32,
457    /// Blend transition speed (units/second).
458    pub transition_speed: f32,
459}
460
461impl ClimateInterpolator {
462    pub fn new(zone_a: BiomeZone, zone_b: BiomeZone, lat: f32) -> Self {
463        let cycle_a = SeasonalCycle::from_biome(zone_a.biome, lat);
464        let cycle_b = SeasonalCycle::from_biome(zone_b.biome, lat);
465        Self {
466            zone_a,
467            zone_b,
468            cycle_a,
469            cycle_b,
470            blend: 0.0,
471            target_blend: 0.0,
472            transition_speed: 1e-5,
473        }
474    }
475
476    pub fn tick(&mut self, dt: f32) {
477        let delta = self.target_blend - self.blend;
478        let step  = self.transition_speed * dt;
479        if delta.abs() < step {
480            self.blend = self.target_blend;
481        } else {
482            self.blend += step * delta.signum();
483        }
484    }
485
486    pub fn temperature_at_month(&self, month_frac: f32) -> f32 {
487        let ta = self.cycle_a.temperature_at_month(month_frac);
488        let tb = self.cycle_b.temperature_at_month(month_frac);
489        lerp(ta, tb, self.blend)
490    }
491
492    pub fn humidity_at_month(&self, month_frac: f32) -> f32 {
493        let ha = self.cycle_a.humidity_at_month(month_frac);
494        let hb = self.cycle_b.humidity_at_month(month_frac);
495        lerp(ha, hb, self.blend)
496    }
497
498    pub fn prevailing_wind_speed(&self) -> f32 {
499        lerp(self.zone_a.mean_wind_speed, self.zone_b.mean_wind_speed, self.blend)
500    }
501}
502
503// ── Weather Pattern ───────────────────────────────────────────────────────────
504
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
506pub enum WeatherPatternKind {
507    HighPressureRidge,
508    LowPressureTrough,
509    ColdFront,
510    WarmFront,
511    OccludedFront,
512    StationaryFront,
513    Anticyclone,
514    Cyclone,
515    Monsoon,
516    TradeWinds,
517    BlockingHigh,
518}
519
520/// A large-scale weather pattern.
521#[derive(Debug, Clone)]
522pub struct WeatherPattern {
523    pub kind: WeatherPatternKind,
524    /// Position of the pattern centre (world x, z).
525    pub centre: [f32; 2],
526    /// Radius of influence (m).
527    pub radius: f32,
528    /// Pattern intensity [0,1].
529    pub intensity: f32,
530    /// Drift velocity (m/s).
531    pub drift: [f32; 2],
532    /// Remaining lifetime (seconds).
533    pub lifetime: f32,
534    /// Associated pressure anomaly (Pa, positive = high, negative = low).
535    pub pressure_anomaly_pa: f32,
536    /// Associated temperature anomaly (°C).
537    pub temp_anomaly_c: f32,
538    /// Associated precipitation modifier [0,2]; 1 = no change.
539    pub precip_modifier: f32,
540}
541
542impl WeatherPattern {
543    pub fn new_high_pressure(cx: f32, cz: f32) -> Self {
544        Self {
545            kind: WeatherPatternKind::HighPressureRidge,
546            centre: [cx, cz],
547            radius: 800_000.0,
548            intensity: 0.8,
549            drift: [1.0, 0.3],
550            lifetime: 7_200.0,
551            pressure_anomaly_pa: 2_500.0,
552            temp_anomaly_c: 3.0,
553            precip_modifier: 0.1,
554        }
555    }
556
557    pub fn new_low_pressure(cx: f32, cz: f32) -> Self {
558        Self {
559            kind: WeatherPatternKind::LowPressureTrough,
560            centre: [cx, cz],
561            radius: 600_000.0,
562            intensity: 0.9,
563            drift: [3.0, -0.5],
564            lifetime: 5_400.0,
565            pressure_anomaly_pa: -3_000.0,
566            temp_anomaly_c: -2.0,
567            precip_modifier: 2.5,
568        }
569    }
570
571    pub fn new_monsoon(cx: f32, cz: f32) -> Self {
572        Self {
573            kind: WeatherPatternKind::Monsoon,
574            centre: [cx, cz],
575            radius: 1_500_000.0,
576            intensity: 1.0,
577            drift: [0.5, 0.1],
578            lifetime: 86_400.0 * 90.0, // ~90 days
579            pressure_anomaly_pa: -1_500.0,
580            temp_anomaly_c: 1.5,
581            precip_modifier: 5.0,
582        }
583    }
584
585    pub fn tick(&mut self, dt: f32) {
586        self.centre[0] += self.drift[0] * dt;
587        self.centre[1] += self.drift[1] * dt;
588        self.lifetime  -= dt;
589        // Decay near end of life
590        if self.lifetime < 600.0 {
591            self.intensity *= 1.0 - dt / 600.0;
592        }
593    }
594
595    /// Influence on temperature at world position.
596    pub fn temp_influence(&self, x: f32, z: f32) -> f32 {
597        let dx = x - self.centre[0];
598        let dz = z - self.centre[1];
599        let dist = (dx * dx + dz * dz).sqrt();
600        if dist >= self.radius { return 0.0; }
601        smoothstep(self.radius, 0.0, dist) * self.temp_anomaly_c * self.intensity
602    }
603
604    /// Influence on precipitation multiplier at world position.
605    pub fn precip_influence(&self, x: f32, z: f32) -> f32 {
606        let dx = x - self.centre[0];
607        let dz = z - self.centre[1];
608        let dist = (dx * dx + dz * dz).sqrt();
609        if dist >= self.radius { return 1.0; }
610        let t = smoothstep(self.radius, 0.0, dist);
611        lerp(1.0, self.precip_modifier, t * self.intensity)
612    }
613
614    pub fn is_alive(&self) -> bool { self.lifetime > 0.0 && self.intensity > 1e-4 }
615}
616
617// ── Storm Front ───────────────────────────────────────────────────────────────
618
619/// A weather front — boundary between air masses.
620#[derive(Debug, Clone)]
621pub struct StormFront {
622    pub kind: WeatherPatternKind,
623    /// Origin point in world (x, z).
624    pub origin: [f32; 2],
625    /// Direction of movement (radians).
626    pub direction: f32,
627    /// Speed (m/s).
628    pub speed: f32,
629    /// Half-width of the frontal zone (m).
630    pub width: f32,
631    /// Length of the front (m).
632    pub length: f32,
633    /// Age (seconds).
634    pub age: f32,
635    /// Maximum lifetime (seconds).
636    pub max_lifetime: f32,
637    /// Temperature change across the front (°C, positive = warmer air behind).
638    pub temp_gradient_c: f32,
639    /// Precipitation intensity on the leading edge [0,1].
640    pub leading_precip: f32,
641    /// Precipitation intensity on the trailing edge [0,1].
642    pub trailing_precip: f32,
643    /// Whether the front has become occluded.
644    pub occluded: bool,
645}
646
647impl StormFront {
648    pub fn cold_front(ox: f32, oz: f32, direction: f32) -> Self {
649        Self {
650            kind: WeatherPatternKind::ColdFront,
651            origin: [ox, oz],
652            direction,
653            speed: 8.0,
654            width: 50_000.0,
655            length: 2_000_000.0,
656            age: 0.0,
657            max_lifetime: 86_400.0,
658            temp_gradient_c: -8.0,
659            leading_precip: 0.7,
660            trailing_precip: 0.1,
661            occluded: false,
662        }
663    }
664
665    pub fn warm_front(ox: f32, oz: f32, direction: f32) -> Self {
666        Self {
667            kind: WeatherPatternKind::WarmFront,
668            origin: [ox, oz],
669            direction,
670            speed: 4.0,
671            width: 120_000.0,
672            length: 1_500_000.0,
673            age: 0.0,
674            max_lifetime: 72_000.0,
675            temp_gradient_c: 6.0,
676            leading_precip: 0.4,
677            trailing_precip: 0.6,
678            occluded: false,
679        }
680    }
681
682    pub fn tick(&mut self, dt: f32) {
683        self.age += dt;
684        self.origin[0] += self.direction.cos() * self.speed * dt;
685        self.origin[1] += self.direction.sin() * self.speed * dt;
686        // Fronts slow and widen (occlude) with age
687        if self.age > self.max_lifetime * 0.7 && !self.occluded {
688            self.occluded = true;
689            self.speed   *= 0.3;
690            self.kind     = WeatherPatternKind::OccludedFront;
691        }
692    }
693
694    /// Signed distance from point (x,z) to the frontal boundary.
695    /// Negative = ahead of the front, positive = behind.
696    pub fn signed_distance(&self, x: f32, z: f32) -> f32 {
697        let dx = x - self.origin[0];
698        let dz = z - self.origin[1];
699        // Project onto front normal (direction the front is moving)
700        -(dx * self.direction.cos() + dz * self.direction.sin())
701    }
702
703    /// Temperature anomaly at world position.
704    pub fn temp_at(&self, x: f32, z: f32) -> f32 {
705        let sd = self.signed_distance(x, z);
706        if sd.abs() > self.width { return 0.0; }
707        let t = (sd / self.width).clamp(-1.0, 1.0);
708        self.temp_gradient_c * t * (1.0 - self.age / self.max_lifetime).max(0.0)
709    }
710
711    /// Precipitation multiplier at world position.
712    pub fn precip_at(&self, x: f32, z: f32) -> f32 {
713        let sd = self.signed_distance(x, z);
714        if sd.abs() > self.width { return 0.0; }
715        let t = ((sd / self.width) + 1.0) * 0.5; // 0 = behind, 1 = ahead
716        lerp(self.trailing_precip, self.leading_precip, t)
717    }
718
719    pub fn is_alive(&self) -> bool { self.age < self.max_lifetime }
720}
721
722// ── Heat Wave ─────────────────────────────────────────────────────────────────
723
724/// A heat wave event — sustained anomalously high temperatures.
725#[derive(Debug, Clone)]
726pub struct HeatWave {
727    /// Remaining duration (seconds).
728    pub duration_s: f32,
729    /// Peak temperature anomaly (°C).
730    pub peak_anomaly_c: f32,
731    /// Current anomaly (°C) — ramps up and down.
732    pub current_anomaly_c: f32,
733    /// Position of the hot dome centre (world x, z).
734    pub centre: [f32; 2],
735    /// Radius of influence (m).
736    pub radius: f32,
737    /// Maximum intensity (occurs at mid-event).
738    intensity: f32,
739    elapsed_s: f32,
740}
741
742impl HeatWave {
743    pub fn new(cx: f32, cz: f32, peak_anomaly_c: f32, duration_s: f32) -> Self {
744        Self {
745            duration_s,
746            peak_anomaly_c,
747            current_anomaly_c: 0.0,
748            centre: [cx, cz],
749            radius: 500_000.0,
750            intensity: 0.0,
751            elapsed_s: 0.0,
752        }
753    }
754
755    pub fn tick(&mut self, dt: f32) {
756        self.elapsed_s += dt;
757        let progress = (self.elapsed_s / self.duration_s).clamp(0.0, 1.0);
758        // Bell-shaped intensity curve
759        self.intensity = smoothstep(0.0, 0.3, progress) * smoothstep(1.0, 0.7, progress);
760        self.current_anomaly_c = self.peak_anomaly_c * self.intensity;
761    }
762
763    /// Temperature anomaly at world position.
764    pub fn temp_anomaly(&self, x: f32, z: f32) -> f32 {
765        let dx = x - self.centre[0];
766        let dz = z - self.centre[1];
767        let dist = (dx * dx + dz * dz).sqrt();
768        if dist >= self.radius { return 0.0; }
769        smoothstep(self.radius, 0.0, dist) * self.current_anomaly_c
770    }
771
772    pub fn is_active(&self) -> bool { self.elapsed_s < self.duration_s }
773    pub fn progress(&self) -> f32 { (self.elapsed_s / self.duration_s).clamp(0.0, 1.0) }
774}
775
776// ── Cold Snap ─────────────────────────────────────────────────────────────────
777
778/// A cold snap event — sustained anomalously low temperatures.
779#[derive(Debug, Clone)]
780pub struct ColdSnap {
781    pub duration_s: f32,
782    pub peak_anomaly_c: f32,   // Negative value
783    pub current_anomaly_c: f32,
784    pub centre: [f32; 2],
785    pub radius: f32,
786    /// Wind chill enhancement factor.
787    pub wind_chill_factor: f32,
788    elapsed_s: f32,
789    intensity: f32,
790}
791
792impl ColdSnap {
793    pub fn new(cx: f32, cz: f32, peak_anomaly_c: f32, duration_s: f32) -> Self {
794        assert!(peak_anomaly_c <= 0.0, "Cold snap anomaly must be negative or zero");
795        Self {
796            duration_s,
797            peak_anomaly_c,
798            current_anomaly_c: 0.0,
799            centre: [cx, cz],
800            radius: 600_000.0,
801            wind_chill_factor: 1.5,
802            elapsed_s: 0.0,
803            intensity: 0.0,
804        }
805    }
806
807    pub fn tick(&mut self, dt: f32) {
808        self.elapsed_s += dt;
809        let progress = (self.elapsed_s / self.duration_s).clamp(0.0, 1.0);
810        self.intensity = smoothstep(0.0, 0.25, progress) * smoothstep(1.0, 0.75, progress);
811        self.current_anomaly_c = self.peak_anomaly_c * self.intensity;
812    }
813
814    pub fn temp_anomaly(&self, x: f32, z: f32) -> f32 {
815        let dx = x - self.centre[0];
816        let dz = z - self.centre[1];
817        let dist = (dx * dx + dz * dz).sqrt();
818        if dist >= self.radius { return 0.0; }
819        smoothstep(self.radius, 0.0, dist) * self.current_anomaly_c
820    }
821
822    /// Wind-chill corrected temperature at position given wind speed.
823    pub fn apparent_temp(&self, x: f32, z: f32, base_temp_c: f32, wind_speed_ms: f32) -> f32 {
824        let anomaly = self.temp_anomaly(x, z);
825        let actual = base_temp_c + anomaly;
826        // Siple-Passel wind chill formula (simplified)
827        if wind_speed_ms < 1.4 || actual >= 10.0 { return actual; }
828        let v = wind_speed_ms;
829        13.12 + 0.6215 * actual - 11.37 * v.powf(0.16) + 0.3965 * actual * v.powf(0.16)
830    }
831
832    pub fn is_active(&self) -> bool { self.elapsed_s < self.duration_s }
833}
834
835// ── Weather Transition ────────────────────────────────────────────────────────
836
837/// A smooth transition between two weather states.
838#[derive(Debug, Clone)]
839pub struct WeatherTransition {
840    pub from_pattern: WeatherPatternKind,
841    pub to_pattern: WeatherPatternKind,
842    /// Transition progress [0,1].
843    pub progress: f32,
844    /// Total transition duration (seconds).
845    pub duration_s: f32,
846    elapsed_s: f32,
847}
848
849impl WeatherTransition {
850    pub fn new(from: WeatherPatternKind, to: WeatherPatternKind, duration_s: f32) -> Self {
851        Self {
852            from_pattern: from,
853            to_pattern: to,
854            progress: 0.0,
855            duration_s,
856            elapsed_s: 0.0,
857        }
858    }
859
860    pub fn tick(&mut self, dt: f32) {
861        self.elapsed_s = (self.elapsed_s + dt).min(self.duration_s);
862        self.progress = smoothstep(0.0, self.duration_s, self.elapsed_s);
863    }
864
865    pub fn is_complete(&self) -> bool { self.elapsed_s >= self.duration_s }
866}
867
868// ── Precipitation Chance ──────────────────────────────────────────────────────
869
870/// Probability of precipitation for a given period.
871#[derive(Debug, Clone, Copy)]
872pub struct PrecipitationChance {
873    pub probability: f32,     // 0–1
874    pub expected_intensity: f32, // 0–1
875    pub duration_hours: f32,
876}
877
878impl PrecipitationChance {
879    pub fn none() -> Self {
880        Self { probability: 0.0, expected_intensity: 0.0, duration_hours: 0.0 }
881    }
882    pub fn from_humidity(humidity: f32, temp_c: f32) -> Self {
883        let prob = smoothstep(0.6, 0.95, humidity) * (1.0 - smoothstep(35.0, 45.0, temp_c));
884        let intens = smoothstep(0.7, 1.0, humidity);
885        Self {
886            probability: prob,
887            expected_intensity: intens,
888            duration_hours: prob * 3.0,
889        }
890    }
891}
892
893// ── Wind Pattern ──────────────────────────────────────────────────────────────
894
895/// Prevailing wind pattern for a region.
896#[derive(Debug, Clone)]
897pub struct WindPattern {
898    /// Direction (radians from east).
899    pub direction: f32,
900    /// Speed (m/s).
901    pub speed: f32,
902    /// Variability (standard deviation in direction, radians).
903    pub direction_variability: f32,
904    /// Speed variability (m/s).
905    pub speed_variability: f32,
906    /// Gust factor (peak/mean ratio).
907    pub gust_factor: f32,
908}
909
910impl WindPattern {
911    pub fn westerlies() -> Self {
912        Self { direction: 0.0, speed: 7.0, direction_variability: 0.4, speed_variability: 2.5, gust_factor: 1.8 }
913    }
914    pub fn trade_winds() -> Self {
915        Self { direction: std::f32::consts::PI * 1.25, speed: 6.0, direction_variability: 0.2, speed_variability: 1.5, gust_factor: 1.4 }
916    }
917    pub fn doldrums() -> Self {
918        Self { direction: 0.0, speed: 0.5, direction_variability: 2.0, speed_variability: 1.0, gust_factor: 2.5 }
919    }
920    pub fn polar_easterlies() -> Self {
921        Self { direction: std::f32::consts::PI, speed: 9.0, direction_variability: 0.5, speed_variability: 4.0, gust_factor: 2.2 }
922    }
923
924    /// Sample wind given a noise value `n` in [0,1].
925    pub fn sample(&self, n: f32) -> Vec3 {
926        let dir = self.direction + (n * 2.0 - 1.0) * self.direction_variability;
927        let spd = (self.speed + (n - 0.5) * self.speed_variability * 2.0).max(0.0);
928        Vec3::new(dir.cos() * spd, 0.0, dir.sin() * spd)
929    }
930
931    /// Gust wind at given noise.
932    pub fn gust(&self, n: f32) -> Vec3 {
933        let base = self.sample(n);
934        base.scale(if n > 0.85 { self.gust_factor } else { 1.0 })
935    }
936}
937
938// ── Climate Event ─────────────────────────────────────────────────────────────
939
940#[derive(Debug, Clone)]
941pub enum ClimateEvent {
942    HeatWaveStarted { anomaly_c: f32, duration_s: f32 },
943    ColdSnapStarted { anomaly_c: f32, duration_s: f32 },
944    StormFrontApproaching { kind: WeatherPatternKind, eta_s: f32 },
945    SeasonChanged { from: Season, to: Season },
946    DustStorm { origin: [f32; 2], intensity: f32 },
947    Blizzard { snow_rate_mm_h: f32, wind_speed_ms: f32 },
948}
949
950// ── Climate Cell ──────────────────────────────────────────────────────────────
951
952/// A single cell in a coarse climate model grid.
953#[derive(Debug, Clone)]
954pub struct ClimateCell {
955    pub lat: f32,
956    pub lon: f32,
957    pub biome: BiomeType,
958    pub current_temp_c: f32,
959    pub current_humidity: f32,
960    pub current_pressure_pa: f32,
961    pub wind: Vec3,
962    pub cloud_cover: f32,
963    pub snow_cover_fraction: f32,
964}
965
966impl ClimateCell {
967    pub fn new(lat: f32, lon: f32, biome: BiomeType) -> Self {
968        Self {
969            lat,
970            lon,
971            biome,
972            current_temp_c: biome.mean_annual_temp_c(),
973            current_humidity: 0.5,
974            current_pressure_pa: 101_325.0,
975            wind: Vec3::ZERO,
976            cloud_cover: 0.3,
977            snow_cover_fraction: 0.0,
978        }
979    }
980}
981
982// ── Climate Config ────────────────────────────────────────────────────────────
983
984#[derive(Debug, Clone)]
985pub struct ClimateConfig {
986    /// Latitude of the simulation origin (degrees).
987    pub latitude: f32,
988    /// Longitude of the simulation origin (degrees).
989    pub longitude: f32,
990    /// Scale factor for time acceleration (1.0 = real time).
991    pub time_scale: f32,
992    /// How often (seconds) to potentially trigger extreme events.
993    pub event_check_interval: f32,
994    /// Probability of a heat wave per check.
995    pub heat_wave_probability: f32,
996    /// Probability of a cold snap per check.
997    pub cold_snap_probability: f32,
998    /// Probability of a storm front per check.
999    pub storm_probability: f32,
1000    /// Maximum simultaneous weather patterns.
1001    pub max_patterns: usize,
1002}
1003
1004impl Default for ClimateConfig {
1005    fn default() -> Self {
1006        Self {
1007            latitude: 50.0,
1008            longitude: 0.0,
1009            time_scale: 1.0,
1010            event_check_interval: 3_600.0,
1011            heat_wave_probability: 0.02,
1012            cold_snap_probability: 0.03,
1013            storm_probability: 0.05,
1014            max_patterns: 8,
1015        }
1016    }
1017}
1018
1019// ── Climate System ────────────────────────────────────────────────────────────
1020
1021/// The main climate simulation state.
1022#[derive(Debug, Clone)]
1023pub struct ClimateSystem {
1024    pub config: ClimateConfig,
1025    pub primary_biome: BiomeType,
1026    pub seasonal_cycle: SeasonalCycle,
1027    pub day_night_curve: DayNightCurve,
1028    pub interpolator: Option<ClimateInterpolator>,
1029    pub weather_patterns: Vec<WeatherPattern>,
1030    pub storm_fronts: Vec<StormFront>,
1031    pub heat_waves: Vec<HeatWave>,
1032    pub cold_snaps: Vec<ColdSnap>,
1033    pub active_transitions: Vec<WeatherTransition>,
1034    pub pending_events: Vec<ClimateEvent>,
1035    pub wind_pattern: WindPattern,
1036    /// Grid of climate cells for spatial variation.
1037    pub cells: Vec<ClimateCell>,
1038    pub grid_w: usize,
1039    pub grid_d: usize,
1040    /// Current surface temperature (°C) at simulation origin.
1041    current_temp_c: f32,
1042    /// Current humidity [0,1] at simulation origin.
1043    current_humidity: f32,
1044    /// Noise offset for pseudo-random weather variation.
1045    noise_t: f32,
1046    /// Time since last event check (s).
1047    event_check_accum: f32,
1048    /// Current season.
1049    cached_season: Season,
1050    /// Previous season (for detecting transitions).
1051    prev_season: Season,
1052}
1053
1054impl ClimateSystem {
1055    pub fn new(latitude: f32) -> Self {
1056        Self::with_config(ClimateConfig { latitude, ..ClimateConfig::default() })
1057    }
1058
1059    pub fn with_config(config: ClimateConfig) -> Self {
1060        let biome = Self::biome_for_latitude(config.latitude);
1061        let cycle = SeasonalCycle::from_biome(biome, config.latitude);
1062        let mean_temp = biome.mean_annual_temp_c();
1063        let amp = biome.diurnal_range_c();
1064        let day_len = DayNightCurve::day_length_from_lat(config.latitude, 172.0); // summer solstice
1065        let curve = DayNightCurve::new(mean_temp, amp, day_len, 12.0);
1066
1067        let wind = if config.latitude.abs() > 60.0 {
1068            WindPattern::polar_easterlies()
1069        } else if config.latitude.abs() < 20.0 {
1070            WindPattern::trade_winds()
1071        } else {
1072            WindPattern::westerlies()
1073        };
1074
1075        // Build a small grid of climate cells
1076        let grid_w = 8usize;
1077        let grid_d = 8usize;
1078        let mut cells = Vec::with_capacity(grid_w * grid_d);
1079        for gz in 0..grid_d {
1080            for gx in 0..grid_w {
1081                let lat_offset = (gz as f32 - grid_d as f32 * 0.5) * 0.5;
1082                let lon_offset = (gx as f32 - grid_w as f32 * 0.5) * 0.5;
1083                let cell_lat = config.latitude + lat_offset;
1084                let cell_biome = Self::biome_for_latitude(cell_lat);
1085                cells.push(ClimateCell::new(cell_lat, config.longitude + lon_offset, cell_biome));
1086            }
1087        }
1088
1089        let northern = config.latitude >= 0.0;
1090        let season = Season::from_day(0.0, northern);
1091
1092        Self {
1093            config,
1094            primary_biome: biome,
1095            seasonal_cycle: cycle,
1096            day_night_curve: curve,
1097            interpolator: None,
1098            weather_patterns: Vec::new(),
1099            storm_fronts: Vec::new(),
1100            heat_waves: Vec::new(),
1101            cold_snaps: Vec::new(),
1102            active_transitions: Vec::new(),
1103            pending_events: Vec::new(),
1104            wind_pattern: wind,
1105            cells,
1106            grid_w,
1107            grid_d,
1108            current_temp_c: biome.mean_annual_temp_c(),
1109            current_humidity: 0.55,
1110            noise_t: 0.0,
1111            event_check_accum: 0.0,
1112            cached_season: season,
1113            prev_season: season,
1114        }
1115    }
1116
1117    // ── Tick ─────────────────────────────────────────────────────────────────
1118
1119    pub fn tick(&mut self, dt: f32, day_of_year: f32, time_of_day: f32) {
1120        self.noise_t += dt * 0.001;
1121        let scaled_dt = dt * self.config.time_scale;
1122
1123        // Season
1124        let northern = self.config.latitude >= 0.0;
1125        self.prev_season = self.cached_season;
1126        self.cached_season = Season::from_day(day_of_year, northern);
1127        if self.cached_season != self.prev_season {
1128            self.pending_events.push(ClimateEvent::SeasonChanged {
1129                from: self.prev_season,
1130                to: self.cached_season,
1131            });
1132        }
1133
1134        // Update day/night curve for current day length
1135        let day_len = DayNightCurve::day_length_from_lat(self.config.latitude, day_of_year);
1136        let month   = SeasonalCycle::day_to_month_frac(day_of_year);
1137        let mean_tc = self.seasonal_cycle.temperature_at_month(month);
1138        let amp     = self.primary_biome.diurnal_range_c();
1139        self.day_night_curve = DayNightCurve::new(mean_tc, amp, day_len, 12.0);
1140
1141        // Base temperature from day/night cycle
1142        let mut temp_c = self.day_night_curve.temperature_at(time_of_day);
1143
1144        // Base humidity from seasonal cycle
1145        let mut humidity = self.seasonal_cycle.humidity_at_month(month);
1146
1147        // Apply weather pattern anomalies
1148        for pat in &self.weather_patterns {
1149            temp_c   += pat.temp_influence(0.0, 0.0);
1150            humidity *= pat.precip_influence(0.0, 0.0).clamp(0.1, 3.0);
1151        }
1152
1153        // Apply storm fronts
1154        for front in &self.storm_fronts {
1155            temp_c   += front.temp_at(0.0, 0.0);
1156            humidity  = (humidity + front.precip_at(0.0, 0.0) * 0.3).clamp(0.0, 1.0);
1157        }
1158
1159        // Apply heat waves
1160        for hw in &self.heat_waves {
1161            temp_c += hw.temp_anomaly(0.0, 0.0);
1162        }
1163
1164        // Apply cold snaps
1165        for cs in &self.cold_snaps {
1166            temp_c += cs.temp_anomaly(0.0, 0.0);
1167        }
1168
1169        // Small random variation (mesoscale noise)
1170        let noise_temp = (value_noise_2d(self.noise_t, 0.0) * 2.0 - 1.0) * 0.5;
1171        let noise_hum  = (value_noise_2d(0.0, self.noise_t + 3.5) * 2.0 - 1.0) * 0.02;
1172        temp_c   += noise_temp;
1173        humidity  = (humidity + noise_hum).clamp(0.0, 1.0);
1174
1175        self.current_temp_c   = temp_c;
1176        self.current_humidity = humidity;
1177
1178        // Update all dynamic patterns
1179        for pat in &mut self.weather_patterns { pat.tick(scaled_dt); }
1180        self.weather_patterns.retain(|p| p.is_alive());
1181
1182        for front in &mut self.storm_fronts { front.tick(scaled_dt); }
1183        self.storm_fronts.retain(|f| f.is_alive());
1184
1185        for hw in &mut self.heat_waves { hw.tick(scaled_dt); }
1186        self.heat_waves.retain(|h| h.is_active());
1187
1188        for cs in &mut self.cold_snaps { cs.tick(scaled_dt); }
1189        self.cold_snaps.retain(|c| c.is_active());
1190
1191        for tr in &mut self.active_transitions { tr.tick(scaled_dt); }
1192        self.active_transitions.retain(|t| !t.is_complete());
1193
1194        if let Some(ref mut interp) = self.interpolator { interp.tick(scaled_dt); }
1195
1196        // Stochastic event checking
1197        self.event_check_accum += scaled_dt;
1198        if self.event_check_accum >= self.config.event_check_interval {
1199            self.event_check_accum = 0.0;
1200            self.check_extreme_events();
1201        }
1202
1203        // Update grid cells
1204        self.update_cells(day_of_year, time_of_day, month);
1205    }
1206
1207    fn check_extreme_events(&mut self) {
1208        let rng = value_noise_2d(self.noise_t, self.noise_t * 1.3);
1209
1210        // Heat wave
1211        if rng < self.config.heat_wave_probability
1212            && self.heat_waves.is_empty()
1213            && self.current_temp_c > 15.0
1214        {
1215            let anomaly = 8.0 + rng * 10.0;
1216            let dur     = 86_400.0 * (3.0 + rng * 7.0);
1217            let cx = (value_noise_2d(self.noise_t * 2.0, 0.0) * 2.0 - 1.0) * 200_000.0;
1218            let cz = (value_noise_2d(0.0, self.noise_t * 2.0 + 1.0) * 2.0 - 1.0) * 200_000.0;
1219            self.heat_waves.push(HeatWave::new(cx, cz, anomaly, dur));
1220            self.pending_events.push(ClimateEvent::HeatWaveStarted { anomaly_c: anomaly, duration_s: dur });
1221        }
1222
1223        // Cold snap
1224        let rng2 = value_noise_2d(self.noise_t * 1.7, self.noise_t * 0.9 + 5.0);
1225        if rng2 < self.config.cold_snap_probability
1226            && self.cold_snaps.is_empty()
1227            && self.current_temp_c < 10.0
1228        {
1229            let anomaly = -(6.0 + rng2 * 15.0);
1230            let dur     = 86_400.0 * (2.0 + rng2 * 5.0);
1231            let cx = (value_noise_2d(self.noise_t * 0.8, 0.0) * 2.0 - 1.0) * 300_000.0;
1232            let cz = (value_noise_2d(0.0, self.noise_t * 0.8 + 2.3) * 2.0 - 1.0) * 300_000.0;
1233            self.cold_snaps.push(ColdSnap::new(cx, cz, anomaly, dur));
1234            self.pending_events.push(ClimateEvent::ColdSnapStarted { anomaly_c: anomaly, duration_s: dur });
1235        }
1236
1237        // Storm front
1238        let rng3 = value_noise_2d(self.noise_t * 2.3, self.noise_t * 1.5 + 8.0);
1239        if rng3 < self.config.storm_probability
1240            && self.storm_fronts.len() < 3
1241            && self.weather_patterns.len() < self.config.max_patterns
1242        {
1243            let dir = rng3 * std::f32::consts::TAU;
1244            let kind_rng = value_noise_2d(self.noise_t + 11.0, 0.3);
1245            let front = if kind_rng < 0.5 {
1246                StormFront::cold_front(-500_000.0, -500_000.0, dir)
1247            } else {
1248                StormFront::warm_front(-300_000.0, -300_000.0, dir)
1249            };
1250            let kind = front.kind;
1251            self.storm_fronts.push(front);
1252            self.pending_events.push(ClimateEvent::StormFrontApproaching {
1253                kind,
1254                eta_s: 500_000.0 / 8.0,
1255            });
1256        }
1257
1258        // Blizzard — only in winter with cold + moisture
1259        let rng4 = value_noise_2d(self.noise_t * 3.1, 0.77);
1260        if self.current_temp_c < -2.0
1261            && self.current_humidity > 0.7
1262            && rng4 < 0.04
1263        {
1264            self.pending_events.push(ClimateEvent::Blizzard {
1265                snow_rate_mm_h: 10.0 + rng4 * 40.0,
1266                wind_speed_ms:  8.0 + rng4 * 20.0,
1267            });
1268            // Add a low pressure pattern to drive it
1269            if self.weather_patterns.len() < self.config.max_patterns {
1270                let cx = (value_noise_2d(self.noise_t, 4.4) * 2.0 - 1.0) * 400_000.0;
1271                let cz = (value_noise_2d(4.4, self.noise_t) * 2.0 - 1.0) * 400_000.0;
1272                self.weather_patterns.push(WeatherPattern::new_low_pressure(cx, cz));
1273            }
1274        }
1275    }
1276
1277    fn update_cells(&mut self, day_of_year: f32, time_of_day: f32, month: f32) {
1278        for (i, cell) in self.cells.iter_mut().enumerate() {
1279            let n  = value_noise_2d(i as f32 * 0.17 + self.noise_t, i as f32 * 0.11);
1280            let cycle = SeasonalCycle::from_biome(cell.biome, cell.lat);
1281            let amp   = cell.biome.diurnal_range_c();
1282            let day_l = DayNightCurve::day_length_from_lat(cell.lat, day_of_year);
1283            let curve = DayNightCurve::new(cycle.temperature_at_month(month), amp, day_l, 12.0);
1284            cell.current_temp_c    = curve.temperature_at(time_of_day) + (n * 2.0 - 1.0) * 0.5;
1285            cell.current_humidity  = (cycle.humidity_at_month(month) + (n - 0.5) * 0.05).clamp(0.0, 1.0);
1286            cell.cloud_cover = smoothstep(0.5, 0.85, cell.current_humidity);
1287            cell.wind = self.wind_pattern.sample(n);
1288            // Snow cover
1289            if cell.current_temp_c < 0.0 {
1290                cell.snow_cover_fraction = (cell.snow_cover_fraction + 0.0001).min(1.0);
1291            } else {
1292                cell.snow_cover_fraction = (cell.snow_cover_fraction - 0.0002).max(0.0);
1293            }
1294        }
1295    }
1296
1297    // ── Public Queries ────────────────────────────────────────────────────────
1298
1299    /// Current surface temperature at the simulation origin.
1300    pub fn surface_temperature(&self, time_of_day: f32, day_of_year: f32) -> f32 {
1301        let month = SeasonalCycle::day_to_month_frac(day_of_year);
1302        let mean_tc = self.seasonal_cycle.temperature_at_month(month);
1303        let amp = self.primary_biome.diurnal_range_c();
1304        let day_len = DayNightCurve::day_length_from_lat(self.config.latitude, day_of_year);
1305        let curve = DayNightCurve::new(mean_tc, amp, day_len, 12.0);
1306        let base = curve.temperature_at(time_of_day);
1307        // Apply current anomalies
1308        let hw_anom: f32 = self.heat_waves.iter().map(|h| h.current_anomaly_c).sum();
1309        let cs_anom: f32 = self.cold_snaps.iter().map(|c| c.current_anomaly_c).sum();
1310        base + hw_anom + cs_anom
1311    }
1312
1313    /// Return Kelvin surface temperature (convenience wrapper).
1314    pub fn surface_temperature_k(&self, time_of_day: f32, day_of_year: f32) -> f32 {
1315        self.surface_temperature(time_of_day, day_of_year) + 273.15
1316    }
1317
1318    pub fn current_season(&self, day_of_year: f32) -> Season {
1319        Season::from_day(day_of_year, self.config.latitude >= 0.0)
1320    }
1321
1322    /// Current humidity at origin, incorporating weather patterns.
1323    pub fn current_humidity(&self) -> f32 { self.current_humidity }
1324
1325    /// Current temperature at origin.
1326    pub fn current_temperature_c(&self) -> f32 { self.current_temp_c }
1327
1328    /// Drain pending events (calling code should consume these each frame).
1329    pub fn drain_events(&mut self) -> Vec<ClimateEvent> {
1330        let mut out = Vec::new();
1331        std::mem::swap(&mut self.pending_events, &mut out);
1332        out
1333    }
1334
1335    /// Manually trigger a heat wave.
1336    pub fn trigger_heat_wave(&mut self, peak_c: f32, duration_s: f32) {
1337        self.heat_waves.push(HeatWave::new(0.0, 0.0, peak_c, duration_s));
1338        self.pending_events.push(ClimateEvent::HeatWaveStarted { anomaly_c: peak_c, duration_s });
1339    }
1340
1341    /// Manually trigger a cold snap.
1342    pub fn trigger_cold_snap(&mut self, peak_c: f32, duration_s: f32) {
1343        debug_assert!(peak_c <= 0.0);
1344        self.cold_snaps.push(ColdSnap::new(0.0, 0.0, peak_c, duration_s));
1345        self.pending_events.push(ClimateEvent::ColdSnapStarted { anomaly_c: peak_c, duration_s });
1346    }
1347
1348    /// Manually add a weather pattern.
1349    pub fn add_pattern(&mut self, pat: WeatherPattern) {
1350        if self.weather_patterns.len() < self.config.max_patterns {
1351            self.weather_patterns.push(pat);
1352        }
1353    }
1354
1355    /// Add a storm front.
1356    pub fn add_storm_front(&mut self, front: StormFront) {
1357        self.storm_fronts.push(front);
1358    }
1359
1360    /// Return a climate summary for UI or logging.
1361    pub fn summary(&self) -> ClimateSummary {
1362        ClimateSummary {
1363            biome: self.primary_biome,
1364            season: self.cached_season,
1365            temp_c: self.current_temp_c,
1366            humidity: self.current_humidity,
1367            active_heat_waves: self.heat_waves.len(),
1368            active_cold_snaps: self.cold_snaps.len(),
1369            active_storm_fronts: self.storm_fronts.len(),
1370            active_patterns: self.weather_patterns.len(),
1371        }
1372    }
1373
1374    /// Select a plausible biome for the given latitude.
1375    fn biome_for_latitude(lat: f32) -> BiomeType {
1376        let abs_lat = lat.abs();
1377        if abs_lat < 10.0      { BiomeType::TropicalRainforest }
1378        else if abs_lat < 20.0 { BiomeType::TropicalSavanna    }
1379        else if abs_lat < 30.0 { BiomeType::HotDesert          }
1380        else if abs_lat < 40.0 { BiomeType::MediterraneanShrubland }
1381        else if abs_lat < 55.0 { BiomeType::TemperateDeciduousForest }
1382        else if abs_lat < 65.0 { BiomeType::BorealForest       }
1383        else if abs_lat < 75.0 { BiomeType::Tundra             }
1384        else                   { BiomeType::PolarIce           }
1385    }
1386}
1387
1388impl Default for ClimateSystem {
1389    fn default() -> Self { Self::new(51.5) }
1390}
1391
1392// ── Climate Summary ───────────────────────────────────────────────────────────
1393
1394#[derive(Debug, Clone, Copy)]
1395pub struct ClimateSummary {
1396    pub biome: BiomeType,
1397    pub season: Season,
1398    pub temp_c: f32,
1399    pub humidity: f32,
1400    pub active_heat_waves: usize,
1401    pub active_cold_snaps: usize,
1402    pub active_storm_fronts: usize,
1403    pub active_patterns: usize,
1404}
1405
1406impl ClimateSummary {
1407    pub fn describe(&self) -> &'static str {
1408        if self.active_heat_waves > 0 { return "Heat wave"; }
1409        if self.active_cold_snaps > 0 { return "Cold snap"; }
1410        if self.active_storm_fronts > 0 { return "Stormy"; }
1411        match (self.season, self.temp_c as i32) {
1412            (Season::Summer, t) if t > 28 => "Hot and sunny",
1413            (Season::Winter, t) if t < 0  => "Cold and clear",
1414            (_, _) if self.humidity > 0.8  => "Humid and overcast",
1415            (Season::Spring, _)            => "Mild spring",
1416            (Season::Autumn, _)            => "Crisp autumn",
1417            _                              => "Temperate",
1418        }
1419    }
1420}