Skip to main content

bevy_elements/climate/
weather.rs

1//! Weather: the presets, the blended state the rest of the crate reads, the director that forces
2//! and releases it, and the seeded hourly scheduler.
3
4use std::time::Duration;
5
6use bevy::prelude::*;
7
8use super::model::Climate;
9use super::season::Season;
10use crate::celestial::{CivilDate, GeoLocation, WorldClock};
11
12/// A named weather state the scheduler moves between.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Reflect)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15pub enum WeatherPreset {
16    /// Clear sky.
17    #[default]
18    Clear,
19    /// Broken cumulus.
20    PartlyCloudy,
21    /// Full stratus cover, dry.
22    Overcast,
23    /// Radiation or advection fog, 300 m visibility.
24    Fog,
25    /// Light rain, 0.5 mm/h.
26    Drizzle,
27    /// Steady rain, 4 mm/h.
28    Rain,
29    /// Convective storm: 25 mm/h, lightning 6 strikes per minute within 8 km.
30    Thunderstorm,
31    /// Steady snowfall, 1.5 mm/h water equivalent.
32    Snow,
33    /// Wind-driven snow, 4 mm/h water equivalent, 200 m visibility.
34    Blizzard,
35    /// Wind-raised dust, 800 m visibility.
36    DustStorm,
37}
38
39/// The target values a preset blends toward. Wind speed here is before scaling by
40/// `Climate::mean_wind_ms / 4`.
41#[derive(Clone, Copy, Debug, PartialEq)]
42struct Target {
43    cover: f32,
44    mm_h: f32,
45    rh: f32,
46    wind: f32,
47    gust: f32,
48    visibility: f32,
49    storm: f32,
50    dust: f32,
51    delta_t: f32,
52}
53
54#[rustfmt::skip]
55fn target(preset: WeatherPreset) -> Target {
56    use WeatherPreset::*;
57    let t = |cover, mm_h, rh, wind, gust, visibility, storm, dust, delta_t| Target { cover, mm_h, rh, wind, gust, visibility, storm, dust, delta_t };
58    match preset {
59        Clear =>        t(0.05, 0.0,  0.50, 3.0,  0.20, 40_000.0, 0.0, 0.0,  2.0),
60        PartlyCloudy => t(0.40, 0.0,  0.60, 4.0,  0.30, 30_000.0, 0.0, 0.0,  1.0),
61        Overcast =>     t(0.95, 0.0,  0.75, 5.0,  0.30, 20_000.0, 0.0, 0.0, -1.0),
62        Fog =>          t(0.60, 0.0,  0.99, 1.0,  0.05,    300.0, 0.0, 0.0, -1.0),
63        Drizzle =>      t(0.95, 0.5,  0.92, 4.0,  0.30,  5_000.0, 0.0, 0.0, -2.0),
64        Rain =>         t(1.00, 4.0,  0.95, 7.0,  0.50,  3_000.0, 0.0, 0.0, -3.0),
65        Thunderstorm => t(1.00, 25.0, 0.97, 14.0, 0.90,  1_500.0, 6.0, 0.0, -5.0),
66        Snow =>         t(1.00, 1.5,  0.90, 4.0,  0.30,  1_500.0, 0.0, 0.0, -4.0),
67        Blizzard =>     t(1.00, 4.0,  0.90, 18.0, 0.90,    200.0, 0.0, 0.0, -6.0),
68        DustStorm =>    t(0.30, 0.0,  0.15, 16.0, 0.80,    800.0, 0.0, 0.9,  3.0),
69    }
70}
71
72impl WeatherPreset {
73    /// Every preset, in declaration order.
74    pub const ALL: [Self; 10] = [
75        Self::Clear,
76        Self::PartlyCloudy,
77        Self::Overcast,
78        Self::Fog,
79        Self::Drizzle,
80        Self::Rain,
81        Self::Thunderstorm,
82        Self::Snow,
83        Self::Blizzard,
84        Self::DustStorm,
85    ];
86
87    /// Whether the preset precipitates.
88    pub fn is_wet(self) -> bool {
89        target(self).mm_h > 0.0
90    }
91
92    /// Whether the preset only exists in snow (forcing it caps the temperature at −2 °C).
93    pub fn is_snow(self) -> bool {
94        matches!(self, Self::Snow | Self::Blizzard)
95    }
96
97    fn neighbours(self) -> &'static [Self] {
98        use WeatherPreset::*;
99        match self {
100            Clear => &[PartlyCloudy, DustStorm, Fog],
101            PartlyCloudy => &[Clear, Overcast, DustStorm, Fog],
102            Overcast => &[PartlyCloudy, Drizzle, Rain, Snow, Fog],
103            Fog => &[Overcast, Clear, PartlyCloudy],
104            Drizzle => &[Overcast],
105            Rain => &[Overcast, Thunderstorm],
106            Thunderstorm => &[Rain],
107            Snow => &[Overcast, Blizzard],
108            Blizzard => &[Snow],
109            DustStorm => &[Clear, PartlyCloudy],
110        }
111    }
112}
113
114/// The weather now, blended between presets. Read by every domain.
115#[derive(Resource, Clone, Copy, Debug, PartialEq, Reflect)]
116#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
117pub struct Weather {
118    /// Fraction of the sky covered by cloud, 0–1.
119    pub cloud_cover: f32,
120    /// Precipitation rate, liquid-water equivalent, mm/h.
121    pub precipitation_mm_h: f32,
122    /// Air temperature at the climate's reference elevation, °C.
123    pub temperature_c: f32,
124    /// Relative humidity, 0–1.
125    pub relative_humidity: f32,
126    /// Mean wind speed at 10 m above ground, m/s.
127    pub wind_speed_ms: f32,
128    /// Unit XZ direction the wind blows toward.
129    pub wind_direction: Vec2,
130    /// Gust and turbulence intensity, 0–1.
131    pub gustiness: f32,
132    /// Meteorological visibility (2 % contrast), m.
133    pub visibility_m: f32,
134    /// Lightning strikes per minute within 8 km.
135    pub storminess: f32,
136    /// Airborne dust, 0–1.
137    pub dust: f32,
138}
139
140impl Default for Weather {
141    fn default() -> Self {
142        let t = target(WeatherPreset::Clear);
143        Self {
144            cloud_cover: t.cover,
145            precipitation_mm_h: t.mm_h,
146            temperature_c: 15.0,
147            relative_humidity: t.rh,
148            wind_speed_ms: t.wind,
149            wind_direction: Vec2::X,
150            gustiness: t.gust,
151            visibility_m: t.visibility,
152            storminess: t.storm,
153            dust: t.dust,
154        }
155    }
156}
157
158impl Weather {
159    /// Fraction of precipitation falling as snow: 1 at ≤ −1 °C, 0 at ≥ 2 °C, linear between.
160    pub fn snow_fraction(&self) -> f32 {
161        ((2.0 - self.temperature_c) / 3.0).clamp(0.0, 1.0)
162    }
163}
164
165/// A weather transition started: by the scheduler, by [`WeatherDirector::force`] or by
166/// [`WeatherDirector::release`].
167#[derive(Message, Clone, Copy, Debug, PartialEq, Eq)]
168#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
169pub struct WeatherChanged {
170    /// The preset being left.
171    pub from: WeatherPreset,
172    /// The preset being blended toward.
173    pub to: WeatherPreset,
174    /// Whether a [`WeatherDirector::force`] caused it.
175    pub forced: bool,
176}
177
178/// Every value a transition blends.
179#[derive(Clone, Copy, Debug, PartialEq)]
180struct Blend {
181    cover: f32,
182    mm_h: f32,
183    rh: f32,
184    wind: f32,
185    direction: Vec2,
186    gust: f32,
187    visibility: f32,
188    storm: f32,
189    dust: f32,
190    delta_t: f32,
191    /// How far the forced-snow −2 °C cap is applied, 0–1.
192    snow_cap: f32,
193}
194
195impl Blend {
196    fn of(preset: WeatherPreset, climate: &Climate, direction: Vec2, forced: bool) -> Self {
197        let t = target(preset);
198        Self {
199            cover: t.cover,
200            mm_h: t.mm_h,
201            rh: t.rh,
202            wind: t.wind * climate.mean_wind_ms / 4.0,
203            direction,
204            gust: t.gust,
205            visibility: t.visibility,
206            storm: t.storm,
207            dust: t.dust,
208            delta_t: t.delta_t,
209            snow_cap: if forced && preset.is_snow() { 1.0 } else { 0.0 },
210        }
211    }
212
213    fn lerp(self, to: Self, s: f32) -> Self {
214        let l = |a: f32, b: f32| a + (b - a) * s;
215        Self {
216            cover: l(self.cover, to.cover),
217            mm_h: l(self.mm_h, to.mm_h),
218            rh: l(self.rh, to.rh),
219            wind: l(self.wind, to.wind),
220            direction: self
221                .direction
222                .lerp(to.direction, s)
223                .normalize_or(to.direction),
224            gust: l(self.gust, to.gust),
225            visibility: l(self.visibility, to.visibility),
226            storm: l(self.storm, to.storm),
227            dust: l(self.dust, to.dust),
228            delta_t: l(self.delta_t, to.delta_t),
229            snow_cap: l(self.snow_cap, to.snow_cap),
230        }
231    }
232}
233
234#[derive(Clone, Copy, Debug)]
235struct Transition {
236    from: Blend,
237    to: Blend,
238    preset: WeatherPreset,
239    start_h: f64,
240    duration_h: f64,
241}
242
243#[derive(Clone, Copy, Debug)]
244enum Request {
245    Force(WeatherPreset, Duration),
246    Release(Duration),
247}
248
249/// Who decides the weather: the seeded scheduler, or the game through [`force`](Self::force).
250///
251/// The scheduler keeps stepping while weather is forced, so [`release`](Self::release) blends to
252/// the preset an unforced run would show at that hour. Transition durations are game time.
253#[derive(Resource, Clone, Debug, Default)]
254pub struct WeatherDirector {
255    scheduled: WeatherPreset,
256    scheduled_direction: Option<Vec2>,
257    forced: Option<WeatherPreset>,
258    pending: Option<Request>,
259    transition: Option<Transition>,
260    current: Option<Blend>,
261    last_hour: Option<i64>,
262}
263
264impl WeatherDirector {
265    /// Override the scheduler: blend to `preset` over `transition` of game time and hold it until
266    /// [`release`](Self::release). While Snow or Blizzard is forced the temperature is capped at
267    /// −2 °C, so a forced snowfall always falls as snow.
268    pub fn force(&mut self, preset: WeatherPreset, transition: Duration) {
269        self.forced = Some(preset);
270        self.pending = Some(Request::Force(preset, transition));
271    }
272
273    /// Hand control back to the scheduler, blending to its current preset over `transition` of
274    /// game time.
275    pub fn release(&mut self, transition: Duration) {
276        self.forced = None;
277        self.pending = Some(Request::Release(transition));
278    }
279
280    /// The preset the weather is at or blending toward.
281    pub fn current(&self) -> WeatherPreset {
282        match (self.forced, self.transition) {
283            (Some(p), _) => p,
284            (None, Some(t)) => t.preset,
285            (None, None) => self.scheduled,
286        }
287    }
288
289    /// Whether [`force`](Self::force) is in effect.
290    pub fn is_forced(&self) -> bool {
291        self.forced.is_some()
292    }
293
294    fn start(&mut self, preset: WeatherPreset, climate: &Climate, now_h: f64, duration_h: f64) {
295        let direction = self.scheduled_direction.unwrap_or(climate.prevailing_wind);
296        let to = Blend::of(preset, climate, direction, self.forced.is_some());
297        let from = self.current.unwrap_or(to);
298        self.transition = Some(Transition {
299            from,
300            to,
301            preset,
302            start_h: now_h,
303            duration_h,
304        });
305    }
306}
307
308/// SplitMix64 finaliser (Steele, Lea & Flood 2014).
309pub(crate) fn mix64(mut z: u64) -> u64 {
310    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
311    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
312    z ^ (z >> 31)
313}
314
315/// The scheduler's hash of one game hour.
316pub(crate) fn hash64(seed: u64, index: u64) -> u64 {
317    mix64(seed ^ index.wrapping_mul(0x9E37_79B9_7F4A_7C15))
318}
319
320fn unit24(bits: u64) -> f32 {
321    (bits & 0xFF_FFFF) as f32 / 16_777_216.0
322}
323
324/// One hourly decision of the scheduler.
325#[derive(Clone, Copy, Debug, PartialEq)]
326pub(crate) struct Step {
327    pub preset: WeatherPreset,
328    /// Game minutes the blend to `preset` takes, 20–60.
329    pub transition_min: f32,
330    /// Rotation of the prevailing wind for this preset, degrees, ±30.
331    pub wind_turn_deg: f32,
332}
333
334/// Probability of keeping the current preset at an hour boundary.
335const STAY: f32 = 0.85;
336/// Base neighbour weight of Fog (every other base weight is 1), calibrated so the preset
337/// climates spend roughly `fog_share` of their hours in fog.
338const FOG_BASE_WEIGHT: f32 = 0.3;
339/// Base neighbour weight of DustStorm, calibrated so `Climate::desert()` spends roughly
340/// `dust_share` of its hours in dust storms.
341const DUST_BASE_WEIGHT: f32 = 0.2;
342
343/// Decide the preset for absolute hour `index` (local hours since 2000-01-01 00:00) following
344/// `prev`.
345pub(crate) fn schedule(climate: &Climate, southern: bool, prev: WeatherPreset, index: i64) -> Step {
346    let date = CivilDate::new(2000, 1, 1).add_days(index.div_euclid(24));
347    let hour = index.rem_euclid(24) as f32;
348    let doy = f32::from(date.day_of_year()) + hour / 24.0;
349    let temperature = climate.base_temperature_c(doy, hour, southern);
350    let summer = Season::of_month(date.month, southern) == Season::Summer;
351
352    let gated = |p: WeatherPreset| match p {
353        WeatherPreset::Snow | WeatherPreset::Blizzard => temperature > 1.0,
354        WeatherPreset::Rain | WeatherPreset::Drizzle | WeatherPreset::Thunderstorm => {
355            temperature < -1.0
356        }
357        WeatherPreset::DustStorm => climate.dust_share <= 0.0,
358        _ => false,
359    };
360    let weight = |p: WeatherPreset| -> f32 {
361        if gated(p) {
362            return 0.0;
363        }
364        let mut w = 1.0;
365        if p.is_wet() {
366            w *= climate.wet_hour_probability / 0.35;
367        }
368        match p {
369            WeatherPreset::Thunderstorm => {
370                w *= climate.storm_share / 0.05 * if summer { 1.0 } else { 0.3 };
371            }
372            WeatherPreset::Fog => {
373                w *= FOG_BASE_WEIGHT * climate.fog_share / 0.08;
374                if (4.0..=9.0).contains(&hour) && target(prev).rh > 0.85 {
375                    w *= 3.0;
376                }
377            }
378            WeatherPreset::DustStorm => w *= DUST_BASE_WEIGHT * climate.dust_share / 0.08,
379            _ => {}
380        }
381        w.max(0.0)
382    };
383
384    let h = hash64(climate.seed, index as u64);
385    let u_stay = unit24(h >> 40);
386    let u_pick = unit24(h >> 16);
387    let u_duration = (h & 0xFFFF) as f32 / 65_536.0;
388    let u_turn = unit24(mix64(h) >> 40);
389
390    let prev_gated = gated(prev);
391    let preset = if !prev_gated && u_stay < STAY {
392        prev
393    } else {
394        let neighbours = prev.neighbours();
395        let total: f32 = neighbours.iter().map(|&p| weight(p)).sum();
396        if total <= 0.0 {
397            if prev_gated {
398                WeatherPreset::Overcast
399            } else {
400                prev
401            }
402        } else {
403            let mut pick = u_pick * total;
404            let mut chosen = prev;
405            for &p in neighbours {
406                let w = weight(p);
407                if w <= 0.0 {
408                    continue;
409                }
410                chosen = p;
411                if pick < w {
412                    break;
413                }
414                pick -= w;
415            }
416            chosen
417        }
418    };
419    Step {
420        preset,
421        transition_min: 20.0 + 40.0 * u_duration,
422        wind_turn_deg: (u_turn * 2.0 - 1.0) * 30.0,
423    }
424}
425
426fn rotate(v: Vec2, degrees: f32) -> Vec2 {
427    let (s, c) = degrees.to_radians().sin_cos();
428    Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c)
429}
430
431/// The most hours one frame steps the scheduler through; a larger clock jump starts the chain
432/// afresh at the new hour.
433const MAX_STEPS_PER_FRAME: i64 = 24 * 366;
434
435pub(crate) fn run_weather(
436    clock: Res<WorldClock>,
437    location: Res<GeoLocation>,
438    climate: Res<Climate>,
439    mut director: ResMut<WeatherDirector>,
440    mut weather: ResMut<Weather>,
441    mut changed: MessageWriter<WeatherChanged>,
442) {
443    let now_h = clock.hours_since_2000();
444    let index = now_h.floor() as i64;
445    let southern = location.is_southern();
446    let prevailing = climate.prevailing_wind.normalize_or(Vec2::X);
447
448    match director.last_hour {
449        None => {
450            // First frame: the chain starts Clear at this hour, already settled.
451            director.scheduled = WeatherPreset::Clear;
452            director.scheduled_direction = Some(prevailing);
453            if director.forced.is_none() {
454                director.start(WeatherPreset::Clear, &climate, now_h, 0.0);
455            }
456            director.last_hour = Some(index);
457        }
458        Some(last) if index < last || index - last > MAX_STEPS_PER_FRAME => {
459            director.last_hour = Some(index);
460        }
461        Some(last) => {
462            for hour in last + 1..=index {
463                let step = schedule(&climate, southern, director.scheduled, hour);
464                if step.preset == director.scheduled {
465                    continue;
466                }
467                let from = director.current();
468                director.scheduled = step.preset;
469                director.scheduled_direction = Some(rotate(prevailing, step.wind_turn_deg));
470                if director.forced.is_none() {
471                    director.start(
472                        step.preset,
473                        &climate,
474                        hour as f64,
475                        f64::from(step.transition_min) / 60.0,
476                    );
477                    changed.write(WeatherChanged {
478                        from,
479                        to: step.preset,
480                        forced: false,
481                    });
482                }
483            }
484            director.last_hour = Some(index);
485        }
486    }
487
488    if let Some(request) = director.pending.take() {
489        let from = director.current_target_preset();
490        let (to, duration, forced) = match request {
491            Request::Force(preset, d) => (preset, d, true),
492            Request::Release(d) => (director.scheduled, d, false),
493        };
494        director.start(to, &climate, now_h, duration.as_secs_f64() / 3600.0);
495        if from != to {
496            changed.write(WeatherChanged { from, to, forced });
497        }
498    }
499
500    let Some(t) = director.transition else {
501        return;
502    };
503    let s = if t.duration_h <= 0.0 || now_h < t.start_h {
504        1.0
505    } else {
506        let x = ((now_h - t.start_h) / t.duration_h).clamp(0.0, 1.0) as f32;
507        x * x * (3.0 - 2.0 * x)
508    };
509    let blend = t.from.lerp(t.to, s);
510    director.current = Some(blend);
511
512    let doy = f32::from(clock.day_of_year()) + clock.hour as f32 / 24.0;
513    let mut temperature =
514        climate.base_temperature_c(doy, clock.hour as f32, southern) + blend.delta_t;
515    temperature += (temperature.min(-2.0) - temperature) * blend.snow_cap;
516    *weather = Weather {
517        cloud_cover: blend.cover,
518        precipitation_mm_h: blend.mm_h,
519        temperature_c: temperature,
520        relative_humidity: blend.rh,
521        wind_speed_ms: blend.wind,
522        wind_direction: blend.direction,
523        gustiness: blend.gust,
524        visibility_m: blend.visibility,
525        storminess: blend.storm,
526        dust: blend.dust,
527    };
528}
529
530impl WeatherDirector {
531    /// The preset of the transition in progress, before this frame's request is applied.
532    fn current_target_preset(&self) -> WeatherPreset {
533        self.transition.map_or(self.scheduled, |t| t.preset)
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn the_hash_matches_splitmix64() {
543        assert_eq!(mix64(0x9E37_79B9_7F4A_7C15), 0xE220_A839_7B1D_CDAF);
544        assert_eq!(hash64(0, 1), 0xE220_A839_7B1D_CDAF);
545        assert_eq!(hash64(42, 0), 0xA759_EA27_D472_7622);
546        assert_eq!(hash64(42, 1), 0xBDD7_3226_2FEB_6E95);
547        assert_eq!(hash64(42, 8759), 0xAA5E_C7C5_CF51_7539);
548    }
549
550    #[test]
551    fn snow_fraction_ramps_from_minus_one_to_two() {
552        let at = |t| {
553            Weather {
554                temperature_c: t,
555                ..Weather::default()
556            }
557            .snow_fraction()
558        };
559        assert_eq!(at(-10.0), 1.0);
560        assert_eq!(at(-1.0), 1.0);
561        assert!((at(0.5) - 0.5).abs() < 1e-6);
562        assert_eq!(at(2.0), 0.0);
563        assert_eq!(at(30.0), 0.0);
564    }
565
566    fn year_from(climate: &Climate, start: CivilDate, hours: i64) -> Vec<WeatherPreset> {
567        let first = start.days_since_2000() * 24;
568        let mut preset = WeatherPreset::Clear;
569        (0..hours)
570            .map(|h| {
571                preset = schedule(climate, false, preset, first + h).preset;
572                preset
573            })
574            .collect()
575    }
576
577    /// The scheduler's output for a temperate year, seed 42, from 2026-01-01 00:00, folded with
578    /// FNV-1a over the preset discriminants.
579    ///
580    /// **A red result means the scheduler model moved:** a weight, the hash, the graph, the gates
581    /// or the draw order changed, and every seeded world now has different weather. Re-bless only
582    /// when that is intended, in the same commit as the change.
583    #[test]
584    fn the_weather_schedule_is_frozen() {
585        let climate = Climate::temperate().with_seed(42);
586        let presets = year_from(&climate, CivilDate::new(2026, 1, 1), 8760);
587        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
588        for p in &presets {
589            let byte = WeatherPreset::ALL.iter().position(|q| q == p).unwrap_or(0) as u64;
590            hash ^= byte;
591            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
592        }
593        assert_eq!(
594            hash, SCHEDULE_GOLDEN,
595            "weather schedule hash is {hash:#018x}"
596        );
597    }
598
599    const SCHEDULE_GOLDEN: u64 = 0x025f6c432fa413f4;
600
601    fn hour_share(climate: &Climate, preset: WeatherPreset) -> f32 {
602        let mut hits = 0usize;
603        for seed in 1..=8u64 {
604            let year = year_from(
605                &climate.clone().with_seed(seed),
606                CivilDate::new(2026, 1, 1),
607                8760,
608            );
609            hits += year.iter().filter(|p| **p == preset).count();
610        }
611        hits as f32 / (8.0 * 8760.0)
612    }
613
614    /// `fog_share`, `dust_share` and `storm_share` read as fractions of hours, and the base
615    /// weights are calibrated so the preset climates honour that within a few points.
616    #[test]
617    fn preset_climates_spend_about_their_share_in_fog_dust_and_storms() {
618        let within = |x: f32, share: f32| (x - share).abs() < 0.035;
619        let t = Climate::temperate();
620        assert!(within(hour_share(&t, WeatherPreset::Fog), t.fog_share));
621        assert!(within(
622            hour_share(&t, WeatherPreset::Thunderstorm),
623            t.storm_share
624        ));
625        let d = Climate::desert();
626        assert!(within(
627            hour_share(&d, WeatherPreset::DustStorm),
628            d.dust_share
629        ));
630        let tr = Climate::tropical();
631        assert!(within(
632            hour_share(&tr, WeatherPreset::Thunderstorm),
633            tr.storm_share
634        ));
635    }
636
637    #[test]
638    fn runs_last_about_as_long_as_the_stay_probability_implies() {
639        let climate = Climate::temperate().with_seed(7);
640        let presets = year_from(&climate, CivilDate::new(2026, 1, 1), 8760 * 4);
641        let changes = presets.windows(2).filter(|w| w[0] != w[1]).count();
642        let mean_run = presets.len() as f32 / (changes + 1) as f32;
643        // 1 / (1 − 0.85) = 6.67 h, minus the hours gates force a move.
644        assert!((5.0..=7.5).contains(&mean_run), "mean run {mean_run} h");
645    }
646
647    #[test]
648    fn gates_keep_snow_out_of_summer_and_rain_out_of_deep_winter() {
649        let presets = year_from(
650            &Climate::temperate().with_seed(3),
651            CivilDate::new(2026, 7, 1),
652            24 * 31,
653        );
654        assert!(!presets.iter().any(|p| p.is_snow()));
655        let tundra = Climate::tundra().with_seed(3);
656        let presets = year_from(&tundra, CivilDate::new(2026, 1, 10), 24 * 20);
657        assert!(!presets.iter().any(|p| matches!(
658            p,
659            WeatherPreset::Rain | WeatherPreset::Drizzle | WeatherPreset::Thunderstorm
660        )));
661        assert!(presets.iter().any(|p| p.is_snow()));
662    }
663
664    #[test]
665    fn a_climate_without_dust_never_raises_a_dust_storm() {
666        let presets = year_from(
667            &Climate::temperate().with_seed(9),
668            CivilDate::new(2026, 1, 1),
669            8760,
670        );
671        assert!(!presets.contains(&WeatherPreset::DustStorm));
672        let desert = year_from(
673            &Climate::desert().with_seed(9),
674            CivilDate::new(2026, 1, 1),
675            8760,
676        );
677        assert!(desert.contains(&WeatherPreset::DustStorm));
678    }
679}