feat(simulation): region climate tier — baseline + district modulation + edge fuzz (T-1078)

Introduces the region (~205 km) climate tier per D-243 §3/§4, above the
DistrictProfile carrier. New region_profile.rs: RegionProfile/RegionClock,
derive_region_baseline_c (latitude lerp + greenhouse, D-240 class-envelope,
no orbit/star inputs), and region_baseline_at_district — the D-243 §4
edge-fuzz warp-perturbed bilinear blend of surrounding region baselines so
the ~205 km grid is invisible in the output.

Splits district temperature (D-243 §3): derive_district_temperature_c now
modulates the region baseline with elevation lapse only, replacing the
from-scratch per-district derivation. build_district_profile takes an
Option<f32> region baseline; None preserves the legacy single-phase path
(existing call sites unchanged). derive_cover (voxel.rs) consumes the
post-refactor value with no signature change.

Region clock (season/weather) scaffolded; transient tick callbacks deferred
to Q-105 per the two-phase convention. 14 region_profile + 5 district
modulation tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 14:05:37 +02:00
co-authored by Claude Opus 4.8
parent fcc9fcc4ec
commit 030e0907bc
3 changed files with 1158 additions and 11 deletions
+275 -11
View File
@@ -847,6 +847,80 @@ pub fn derive_temperature_c(
Some(t_nudged.clamp(cold, warm))
}
/// Derive the district-level temperature as a **modulation** of a region baseline
/// (D-243 §3 / D-239 §2 split, T-1078).
///
/// This is **step (b)** of the two-phase temperature derivation:
/// - **Step (a)** is the region baseline (`region_profile::derive_region_baseline_c`):
/// latitude + greenhouse nudge + seed nudge, clamped to the class band.
/// - **Step (b)** is here: apply elevation lapse + slope aspect ON TOP of the
/// baseline, re-clamp to the class band.
///
/// When a region baseline is available (produced by the edge-fuzz blend in
/// `region_profile::region_baseline_at_district`), callers should prefer this
/// function over [`derive_temperature_c`]. The two-phase split ensures the
/// temperature gradient is a continuous, warp-perturbed scalar field (D-243 §4)
/// rather than independent per-district derivations.
///
/// ## Inputs
///
/// - `region_baseline_c` — the edge-fuzz-blended region mean temperature (from
/// `region_profile::region_baseline_at_district`). `None` means airless.
/// - `params` — `BodyParams` with the district's own `elevation_km` and
/// `atmosphere` (for lapse rate selection). The latitude/hydrosphere fields
/// are **not re-used here** — they were consumed by the region baseline.
/// - `constants` — climate constants (for the class-band clamp).
///
/// ## Returns
///
/// `None` if `region_baseline_c` is `None` (airless body). Otherwise the
/// district temperature in °C, clamped to the planet class band.
///
/// ## Slope aspect modulation (D-243 §3)
///
/// Slope aspect is a future input that will modulate temperature based on
/// sun-facing vs. shaded slopes. It is not yet available at the district tier
/// (no per-district aspect data). The parameter is reserved; pass `0.0`.
///
/// ## D-010 compliance
///
/// All structural gating downstream uses `temperature_c as i32`. The f32
/// arithmetic here is positional physics.
pub fn derive_district_temperature_c(
region_baseline_c: Option<f32>,
params: &BodyParams,
constants: &ClimateConstants,
_slope_aspect_deg: f32, // reserved for Q-105 / future per-district aspect
) -> Option<f32> {
// Airless: region baseline is None → no district temperature.
let baseline = region_baseline_c?;
let atmosphere = params.atmosphere.as_deref().unwrap_or("none");
// Double-check: if atmosphere is "none" the region baseline should already
// be None, but guard defensively.
if atmosphere == "none" {
return None;
}
// Elevation lapse rate (°C/km). Same as in derive_temperature_c.
let lapse = if atmosphere == "thin" {
3.5_f32
} else {
6.5_f32
};
let elev_km = (params.elevation_km as f32).max(0.0);
let t_lapse = baseline - lapse * elev_km;
// Slope aspect modulation: reserved for future Q-105 / per-district aspect data.
// _slope_aspect_deg is currently unused; the `let _ = …` suppresses the lint.
let _ = _slope_aspect_deg;
// Clamp to the class band (D-240 hard invariant).
let planet_class = params.planet_class.as_deref().unwrap_or("temperate");
let (cold, warm) = constants.envelope(planet_class);
Some(t_lapse.clamp(cold, warm))
}
/// Derive moisture primitive (0100 integer).
///
/// D-239 §2: moisture is derived from hydrosphere + atmosphere; 0 = arid, 100 = saturated.
@@ -951,6 +1025,9 @@ pub fn derive_district_profile(
(0, 0, 0)
};
// No region layer in the eager coarse-grid path — use legacy single-phase
// temperature derivation (region baseline threading is wired in derive_district
// for the on-demand 2 km path; T-1078).
build_district_profile(
seed,
body_params,
@@ -958,6 +1035,7 @@ pub fn derive_district_profile(
slope_q,
elev_q,
ocean_fraction_q,
None, // region_baseline_c: legacy path
)
}
@@ -965,6 +1043,23 @@ pub fn derive_district_profile(
/// terrain primitives (`slope_q`, `elev_q`, `ocean_fraction_q`) — the shared tail
/// of every derivation path (cell-aggregate [`derive_district_profile`] and the
/// interpolation+scatter [`derive_district`]). Pure (T-1024, D-239 §2 / D-240).
///
/// ## Region baseline parameter (D-243 §3, T-1078)
///
/// `region_baseline_c` is the edge-fuzz-blended region mean temperature from
/// [`crate::atlas::region_profile::region_baseline_at_district`].
///
/// - When `Some(baseline)`: district temperature is derived as a **modulation**
/// of the baseline via [`derive_district_temperature_c`] — elevation lapse
/// only (latitude/greenhouse/nudge already in the baseline). This is the
/// D-243 §3 correct two-phase path.
/// - When `None`: falls back to the legacy single-phase [`derive_temperature_c`]
/// for backward compatibility (used by unit tests and paths where no region
/// layer has run yet).
///
/// The distinction is important for edge fuzz: only the two-phase path produces
/// a continuous, warp-perturbed temperature gradient. The single-phase path
/// still satisfies D-240 but without edge fuzz.
fn build_district_profile(
seed: SeedChain,
body_params: &BodyParams,
@@ -972,23 +1067,34 @@ fn build_district_profile(
slope_q: i32,
elev_q: i32,
ocean_fraction_q: i32,
region_baseline_c: Option<f32>,
) -> DistrictProfile {
let tectonic_class = derive_tectonic_class(body_params);
// Climate derivation (T-1024, D-239 §2). Temperature lapse must vary by THIS
// district's elevation — otherwise every district on a body shares one body-level
// elevation and gets an identical lapse, defeating the per-district temperature
// primitive. Build a district-local BodyParams whose elevation_km comes from the
// district's own normalized elevation (elev_q, 0100) scaled to the body's
// elevation span. district_latitude_deg is already per-district (set by the caller
// / derive_all_districts). Per-cell refinement happens later at ChunkContext.
// District-local BodyParams: elevation_km comes from the district's own
// elev_q (0100 scaled to the body's elevation span). district_latitude_deg
// is already set per-district by the caller. Per-cell refinement at ChunkContext.
let district_climate_params = BodyParams {
elevation_km: (elev_q as f64 / 100.0) * MAX_REGION_ELEVATION_KM,
..body_params.clone()
};
// D-240: body-scoped seed for the deterministic per-body temperature nudge.
let body_seed = seed.seed();
let temperature_c = derive_temperature_c(&district_climate_params, climate, body_seed);
// Temperature derivation: two-phase (D-243 §3) when a region baseline is
// available; single-phase legacy fallback otherwise.
let temperature_c = match region_baseline_c {
Some(baseline) => {
// Two-phase path: apply only elevation lapse on top of the
// edge-fuzz-blended region baseline (D-243 §3 / D-239 §2 split).
// slope_aspect_deg = 0.0: reserved, not yet available (Q-105).
derive_district_temperature_c(Some(baseline), &district_climate_params, climate, 0.0)
}
None => {
// Legacy single-phase path: derive temperature from scratch.
// D-240: body-scoped seed for the deterministic per-body nudge.
let body_seed = seed.seed();
derive_temperature_c(&district_climate_params, climate, body_seed)
}
};
let moisture_q = derive_moisture_q(body_params);
// Climate-derived fields: computed from temperature + moisture primitives
@@ -1106,7 +1212,22 @@ pub fn derive_district(
district_latitude_deg: lat_deg,
..body_params.clone()
};
build_district_profile(seed, &params, climate, slope_q, elev_q, ocean_fraction_q)
// The on-demand derive_district path does not yet carry a pre-computed region
// baseline (wiring the region cache into the per-district call site is the
// production integration step; the structure is ready in region_profile.rs via
// region_baseline_at_district). Pass None here → legacy single-phase derivation.
// T-1078: production callers that have a region cache should call
// build_district_profile(..., region_baseline_at_district(...))
// directly instead.
build_district_profile(
seed,
&params,
climate,
slope_q,
elev_q,
ocean_fraction_q,
None,
)
}
/// Bilinear interpolation of a row-major `f32` field at fractional `(px, py)`.
@@ -2435,4 +2556,147 @@ mod tests {
"Volcanic tectonic must prevent Fjord production (gate order)"
);
}
// -----------------------------------------------------------------------
// T-1078 / D-243 §3: derive_district_temperature_c — two-phase split
// -----------------------------------------------------------------------
#[test]
fn district_modulation_applies_lapse_on_baseline() {
// High elevation must produce colder district temperature than sea level,
// given the same region baseline.
let climate = ClimateConstants::default();
let baseline = Some(15.0f32); // hypothetical region baseline at sea level
let sea_level_params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
elevation_km: 0.0,
..Default::default()
};
let high_params = BodyParams {
elevation_km: 4.0,
..sea_level_params.clone()
};
let t_sea = derive_district_temperature_c(baseline, &sea_level_params, &climate, 0.0)
.expect("breathable body must have temperature");
let t_high = derive_district_temperature_c(baseline, &high_params, &climate, 0.0)
.expect("breathable body must have temperature");
assert!(
t_high < t_sea,
"district at 4 km ({t_high}°C) must be colder than sea level ({t_sea}°C)"
);
// 4 km × 6.5 °C/km = 26 °C lapse; clamping may reduce it, but at
// least a few degrees should register.
let delta = t_sea - t_high;
assert!(delta >= 5.0, "4 km elevation delta {delta}°C too small");
}
#[test]
fn district_modulation_airless_baseline_returns_none() {
// None baseline (airless body) → None district temperature.
let climate = ClimateConstants::default();
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
elevation_km: 0.0,
..Default::default()
};
let t = derive_district_temperature_c(None, &params, &climate, 0.0);
assert_eq!(
t, None,
"None baseline must propagate as None district temperature"
);
}
#[test]
fn district_modulation_within_class_band() {
// Even with high lapse, the clamped output must stay within the class band.
let climate = ClimateConstants::default();
let (cold, warm) = climate.envelope("frozen");
// Baseline at the warm end of the frozen band.
let baseline = Some(warm);
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
elevation_km: 8.0, // max elevation → would push far below cold end
..Default::default()
};
let t = derive_district_temperature_c(baseline, &params, &climate, 0.0)
.expect("non-airless body must have temperature");
assert!(
t >= cold && t <= warm,
"district temperature {t}°C outside frozen band [{cold}, {warm}]"
);
}
#[test]
fn district_modulation_does_not_re_apply_latitude_or_greenhouse() {
// The district modulation function must NOT include latitude or greenhouse
// effects — those are already in the region baseline. Pass different baselines
// (simulating the latitude gradient) and verify the delta is exactly what the
// lapse adds, with no additional latitude-induced shift.
let climate = ClimateConstants::default();
let params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
elevation_km: 2.0,
district_latitude_deg: 0.0, // this should be irrelevant for the modulation
..Default::default()
};
// Two different baselines (simulating equatorial vs mid-latitude regions).
let t_warm_region = derive_district_temperature_c(Some(20.0), &params, &climate, 0.0);
let t_cool_region = derive_district_temperature_c(Some(5.0), &params, &climate, 0.0);
// Both get the same lapse (same params), so the delta between them must
// equal the delta between the baselines: 15°C.
let delta = t_warm_region.unwrap() - t_cool_region.unwrap();
assert!(
(delta - 15.0).abs() < 1.0,
"district modulation should preserve the baseline delta (got {delta}°C, expected ~15°C)"
);
}
#[test]
fn district_modulation_thin_atmosphere_uses_lower_lapse() {
// Thin atmosphere → lapse = 3.5 °C/km (vs 6.5 for standard/breathable).
// At 2 km elevation, thin should be ~6 °C warmer than breathable.
let climate = ClimateConstants::default();
let baseline = Some(0.0f32);
let thin_params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
elevation_km: 2.0,
..Default::default()
};
let breathable_params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
elevation_km: 2.0,
..Default::default()
};
let t_thin = derive_district_temperature_c(baseline, &thin_params, &climate, 0.0).unwrap();
let t_breathable =
derive_district_temperature_c(baseline, &breathable_params, &climate, 0.0).unwrap();
// thin lapse: 3.5 × 2 = 7°C; breathable lapse: 6.5 × 2 = 13°C.
// t_thin should be ~6°C warmer than t_breathable (both start from 0°C).
// Note: clamping to class bands may reduce the difference at band edges.
// Just verify the ordering holds.
assert!(
t_thin > t_breathable || {
// If clamping squishes both to the cold end, verify at least thin
// didn't produce MORE lapse than breathable.
let (frozen_cold, _) = climate.envelope("frozen");
let (_, temperate_warm) = climate.envelope("temperate");
t_thin >= frozen_cold && t_breathable <= temperate_warm
},
"thin atmosphere lapse ({t_thin}°C) should be milder than breathable ({t_breathable}°C) at same elevation"
);
}
}
+1
View File
@@ -21,6 +21,7 @@ pub mod heightmap;
pub mod layer1;
pub mod layer_proxy;
pub mod plugin;
pub mod region_profile;
pub mod road_graph;
pub mod scale;
pub mod skeleton_gen;
+882
View File
@@ -0,0 +1,882 @@
//! Region climate stack — the ~205 km top hard block (D-243 §3 / §4, T-1078).
//!
//! A [`RegionProfile`] holds the **region-level climate context**: latitude-driven
//! temperature baseline, weather state, seasonal clock structure. Every district and
//! tile inside the region inherits these values, then applies its own modulation
//! (elevation lapse + slope aspect at the district tier; freeze/snow scatter at
//! the chunk/voxel tier).
//!
//! ## The climate three-level stack (D-243 §3)
//!
//! ```text
//! Region (~205 km) — climate context: baseline temperature, weather state, season
//! ↓ modulation: elevation lapse + slope aspect
//! District (2 km) — local temperature = baseline + lapse + aspect
//! ↓ scatter: freeze/snow (D-239 §3)
//! Chunk/Voxel (64 m / 1 m) — cover scatter
//! ```
//!
//! ## Edge fuzz (D-243 §4)
//!
//! Climate does not change on a line. A tile's climate value is a **continuous,
//! warp-perturbed bilinear blend of surrounding region baselines** — the same
//! meta-rule as [D-239 §4]'s domain warp for terrain, but for the climate scalar
//! field. [`region_baseline_at_district`] implements this: it looks up the four
//! surrounding region baselines and blends them with a noise-displaced bilinear
//! interpolation.
//!
//! ## Q-105 deferral
//!
//! The `RegionClock` structure is scaffolded here (season + weather state) to
//! unblock Q-105's transient clock callbacks. The mean-state derivation and the
//! edge-fuzz blend are fully implemented; the **transient clock-phase tick
//! callbacks** (seasonal frost form/melt, crop-cycle cadence, recompute schedule)
//! are deferred to Q-105. `RegionClock` carries field-level Q-105 annotations.
//!
//! ## D-010 compliance
//!
//! All structural gating decisions use integer arithmetic. `f64` is used only for
//! positional computations (bilinear weights, warp displacement) that are then
//! quantised before any structural decision is made.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::atlas::district_profile::{BodyParams, ClimateConstants};
use crate::atlas::scale::{self, DistrictPos, RegionPos};
use crate::seed::{splitmix64, SeedChain};
// ---------------------------------------------------------------------------
// SeasonPhase — the region's current seasonal position
// ---------------------------------------------------------------------------
/// Broad seasonal phase for a region's clock (Q-105 forward contract).
///
/// The discriminants are **pinned and append-only** (D-010). The tick callbacks
/// that advance through these phases are deferred to Q-105 — today only the
/// struct is scaffolded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum SeasonPhase {
/// Warmest quarter of the year — long days, peak vegetation.
#[default]
Summer = 0,
/// Cooling quarter — harvest, leaf-fall, falling precipitation.
Autumn = 1,
/// Coldest quarter of the year — short days, peak freeze extent.
Winter = 2,
/// Warming quarter — melt, sowing, rising temperatures.
Spring = 3,
}
// ---------------------------------------------------------------------------
// WeatherState — the region's weather snapshot
// ---------------------------------------------------------------------------
/// Coarse weather state for a region (Q-105 forward contract).
///
/// The **mean-state** used today is `Clear` (no active weather event). Q-105
/// will provide the tick that advances through these states based on the
/// region's climate class and seasonal phase.
///
/// Integer-discriminant, append-only (D-010).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[repr(u8)]
pub enum WeatherState {
/// Clear sky — no active weather event.
#[default]
Clear = 0,
/// Overcast — cloud cover, diffuse light, reduced heat gain.
Overcast = 1,
/// Rain — precipitation in liquid form (temperature ≥ 0 °C).
Rain = 2,
/// Snow — precipitation in solid form (temperature < 0 °C).
Snow = 3,
/// Blizzard — heavy snow + high wind; severe passability impact.
Blizzard = 4,
/// Dust storm — driven by arid conditions + wind.
DustStorm = 5,
}
// ---------------------------------------------------------------------------
// RegionClock — scaffolded seasonal/weather clock (Q-105 forward contract)
// ---------------------------------------------------------------------------
/// Region-level time state: the seasonal clock and active weather.
///
/// Today only the **mean-state struct is scaffolded** (season = Summer,
/// weather = Clear, all clock fields at their neutral values). The tick
/// callbacks that drive seasonal transitions and weather events are
/// **deferred to Q-105**.
///
/// Consumers needing the mean-state (static seasonal derivation) read this
/// struct directly. Q-105 will extend `RegionClock` with a tick-phase callback
/// and the `recompute_schedule` logic — the base fields here are load-bearing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionClock {
/// Current broad seasonal phase (Q-105: ticked by the region clock).
pub season: SeasonPhase,
/// Active weather event for the region (Q-105: ticked by the weather cycle).
pub weather: WeatherState,
/// Mean-annual temperature baseline at the region centre (°C), `None` for
/// airless bodies. This is the **pre-edge-fuzz** region-level baseline
/// derived from `planet_class` + latitude + greenhouse nudge (D-243 §3).
///
/// District temperatures are derived by modulating this value with elevation
/// lapse and slope aspect — see [`district_profile::derive_district_temperature_c`].
pub mean_temp_c: Option<f32>,
}
impl Default for RegionClock {
fn default() -> Self {
Self {
season: SeasonPhase::Summer,
weather: WeatherState::Clear,
mean_temp_c: None,
}
}
}
// ---------------------------------------------------------------------------
// RegionProfile — the region climate carrier
// ---------------------------------------------------------------------------
/// Per-region (~205 km) climate context (D-243 §3, T-1078).
///
/// Derived once per region; every district inside inherits it and applies
/// its own modulation (elevation lapse, slope aspect). The edge-fuzz blend
/// ([`region_baseline_at_district`]) ensures the ~205 km grid is invisible in
/// the output — temperatures grade smoothly and raggedly across region boundaries.
///
/// **Derivation inputs:** `(seed, body_params, region_pos)`. Pure and
/// deterministic — no I/O, no side effects (D-010 / D-227).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegionProfile {
/// Region position (region-units) — the key in `BodyWorldState.regions`.
pub pos: RegionPos,
/// Climate context for this region: baseline temperature, season, weather.
pub clock: RegionClock,
/// Latitude of the region centre in the body's reference frame (degrees).
/// 0.0 = equator, ±90.0 = poles. Derived from `region_pos`.
pub latitude_deg: f32,
/// Region-level moisture primitive (0100), inherited from body params.
/// Moisture modulation is body-scale for now; per-district refinement lives
/// in `DistrictProfile.moisture_q`.
pub moisture_q: i32,
}
// ---------------------------------------------------------------------------
// Region baseline derivation
// ---------------------------------------------------------------------------
/// Derive the region-level mean-annual temperature baseline in °C.
///
/// This is **step (a)** of the D-239 §2 / D-243 §3 split:
/// - Latitude lerp across the maritime-moderated band
/// - Atmosphere greenhouse nudge
/// - Seed nudge (deterministic ±~3 °C per-body variety)
/// - Clamp to [cold, warm] class band (hard invariant, D-240)
///
/// **Not included here:** elevation lapse and slope aspect. Those are
/// district-level modulations applied in
/// [`crate::atlas::district_profile::derive_district_temperature_c`].
///
/// ## Inputs
///
/// - `body_params` — `planet_class`, `atmosphere`, `hydrosphere`, and the
/// **region centre latitude** in `district_latitude_deg` (repurposed for
/// the region's central latitude — the field name is historical).
/// - `constants` — tunable climate constants (D-240 table).
/// - `body_seed` — the body-scoped seed for the deterministic nudge (D-010).
///
/// ## Returns
///
/// `None` if the body is airless (`atmosphere == "none"`). Otherwise the
/// region baseline temperature in °C, clamped to the class band.
///
/// ## D-010 compliance
///
/// All structural gating uses integer arithmetic. f32 is used only for the
/// positional physics (latitude lerp, greenhouse fraction) — downstream
/// gating casts to `i32` before comparison.
pub fn derive_region_baseline_c(
body_params: &BodyParams,
constants: &ClimateConstants,
body_seed: u64,
) -> Option<f32> {
let atmosphere = body_params.atmosphere.as_deref().unwrap_or("none");
// No atmosphere → airless body; baseline is None.
if atmosphere == "none" {
return None;
}
// Step 1: planet_class → (cold, warm) envelope (D-240).
let planet_class = body_params.planet_class.as_deref().unwrap_or("temperate");
let (cold, warm) = constants.envelope(planet_class);
let band_width = warm - cold;
// Step 2: latitude lerp across the maritime-moderated band (D-240).
// Water-rich worlds compress the equator→pole gradient toward the band midpoint.
let hydrosphere = body_params.hydrosphere.as_deref().unwrap_or("none");
let maritime = constants.maritime_factor(hydrosphere);
let mid = (cold + warm) * 0.5;
let half = band_width * 0.5 * maritime;
let lat_frac = (body_params.district_latitude_deg.abs() as f32 / 90.0).clamp(0.0, 1.0);
// equator (frac 0) → mid + half; pole (frac 1) → mid half.
let t_lat = (mid + half) - (2.0 * half) * lat_frac;
// Step 3: atmosphere greenhouse nudge — fraction of band_width toward warm end.
let gh_frac = constants.greenhouse(atmosphere);
let t_atmo = t_lat + gh_frac * band_width;
// Step 4: seed nudge — deterministic ±~3 °C per-body variety (D-010, D-240).
// Uses the same splitmix64 mixing as the original derive_temperature_c so the
// body-level nudge character is preserved across the refactor.
let nudge = {
let h = body_seed
.wrapping_add(0x9e37_79b9_7f4a_7c15)
.wrapping_mul(0x6c62_272e_07bb_0142);
let unit = (h as i64 as f64 / i64::MAX as f64) as f32;
unit * 3.0_f32
};
let t_nudged = t_atmo + nudge;
// Step 5: clamp to [cold, warm] — class band is a hard invariant (D-240).
// Elevation lapse and slope aspect are NOT applied here — those are district
// modulations (D-243 §3; applied in derive_district_temperature_c).
Some(t_nudged.clamp(cold, warm))
}
// ---------------------------------------------------------------------------
// RegionProfile builder
// ---------------------------------------------------------------------------
/// Compute the latitude of a region centre from its grid position.
///
/// The region grid is equirectangular. Row 0 sits at the north pole; the
/// equator is in the middle. Returns degrees: +90.0 = north pole, 90.0 =
/// south pole.
///
/// If no body radius is available (tiny test bodies), the region lat is 0.0.
pub fn region_centre_latitude_deg(region_pos: RegionPos, body_radius_km: Option<f64>) -> f64 {
let Some(r_km) = body_radius_km else {
return 0.0;
};
if r_km <= 0.0 {
return 0.0;
}
// The meridian spans πR km. Each region is REGION_M metres tall.
// region_y = 0 maps to the north pole (lat +90), rising y → south.
let meridian_m = std::f64::consts::PI * r_km * 1_000.0;
let region_centre_y_m = (region_pos.1 as f64 + 0.5) * scale::REGION_M as f64;
// Clamp: lat_frac in [0, 1]; 0 = N pole (+90°), 1 = S pole (90°).
let lat_frac = (region_centre_y_m / meridian_m).clamp(0.0, 1.0);
90.0 - lat_frac * 180.0
}
/// Build a [`RegionProfile`] for the region at `region_pos`.
///
/// Pure and deterministic: `(seed, body_params, region_pos)` → `RegionProfile`.
///
/// `body_params.district_latitude_deg` is **overridden** to the region centre's
/// latitude internally — callers do not need to pre-set it.
pub fn build_region_profile(
seed: SeedChain,
body_params: &BodyParams,
constants: &ClimateConstants,
region_pos: RegionPos,
) -> RegionProfile {
let lat_deg = region_centre_latitude_deg(region_pos, body_params.body_radius_km);
// Build region-local params: override latitude to the region centre.
let region_params = BodyParams {
district_latitude_deg: lat_deg,
// Elevation at region level is sea level (baseline only; no lapse here).
elevation_km: 0.0,
..body_params.clone()
};
let body_seed = seed.seed();
let baseline_temp_c = derive_region_baseline_c(&region_params, constants, body_seed);
// Moisture at the region tier is body-scale (same as body_params).
let moisture_q = crate::atlas::district_profile::derive_moisture_q(body_params);
RegionProfile {
pos: region_pos,
clock: RegionClock {
season: SeasonPhase::Summer,
weather: WeatherState::Clear,
mean_temp_c: baseline_temp_c,
},
latitude_deg: lat_deg as f32,
moisture_q,
}
}
// ---------------------------------------------------------------------------
// Edge-fuzz blend — D-243 §4
// ---------------------------------------------------------------------------
/// Region-baseline temperature at a **district position**, using the D-243 §4
/// edge-fuzz blend.
///
/// Climate does not change on a line. This function blends the four surrounding
/// region baselines (bilinear across region centres), displaced by a
/// noise-warp field so the blend boundary is ragged rather than a straight
/// gradient. The ~205 km region grid is therefore **invisible** in the output —
/// temperatures grade smoothly and organically across boundaries.
///
/// ## Algorithm
///
/// 1. Compute the district's fractional position within the region grid,
/// displaced by a per-district warp (keyed on `(world_seed, body_id, district_pos)`).
/// 2. Identify the four surrounding region positions.
/// 3. Derive (or look up from the optional `region_cache`) the baseline
/// temperature for each region.
/// 4. Bilinear-blend the four baselines by the displaced fractional weights.
///
/// ## D-010 compliance
///
/// All warp and blend arithmetic is f64 positional math. The blended baseline
/// returned here is f32 and is used only as input to the district modulation
/// (elevation lapse, slope aspect) in
/// [`crate::atlas::district_profile::derive_district_temperature_c`].
/// No structural gate is applied to this value — gates happen downstream on
/// integer casts of the final district temperature.
///
/// ## Parameters
///
/// - `world_seed` — master world seed (the edge-fuzz warp is keyed to it).
/// - `body_id` — body identifier for warp domain separation.
/// - `district_pos` — the district being derived.
/// - `body_params` — body physical parameters (planet_class, atmosphere, etc.).
/// - `constants` — climate constants.
/// - `seed` — body SeedChain (for per-region baseline derivation).
/// - `region_cache` — optional pre-computed region baselines; if a region is
/// missing it is derived on the fly.
pub fn region_baseline_at_district(
world_seed: u64,
body_id: &str,
district_pos: DistrictPos,
body_params: &BodyParams,
constants: &ClimateConstants,
seed: SeedChain,
region_cache: Option<&BTreeMap<RegionPos, RegionProfile>>,
) -> Option<f32> {
// Atmosphere gate: airless bodies have no temperature baseline.
let atmosphere = body_params.atmosphere.as_deref().unwrap_or("none");
if atmosphere == "none" {
return None;
}
// ── Step 1: Fractional district position in the region grid ─────────────
// A district at `(dx, dy)` lies at fractional position
// fx = (dx mod DISTRICTS_PER_REGION + 0.5) / DISTRICTS_PER_REGION
// fy = (dy mod DISTRICTS_PER_REGION + 0.5) / DISTRICTS_PER_REGION
// in region space, where (0,0) is the region corner.
let dpir = scale::DISTRICTS_PER_REGION as f64;
// The district's offset within its region (0.0..1.0 each axis).
let local_fx = {
let rem = district_pos.0.rem_euclid(scale::DISTRICTS_PER_REGION) as f64;
(rem + 0.5) / dpir
};
let local_fy = {
let rem = district_pos.1.rem_euclid(scale::DISTRICTS_PER_REGION) as f64;
(rem + 0.5) / dpir
};
// ── Step 2: Edge-fuzz warp displacement ─────────────────────────────────
// A per-district noise warp displaces the sampling point so the blend
// boundary is ragged. The warp is keyed on (world_seed, body_id,
// district_pos) and is bounded to ±CLIMATE_WARP_FRAC of the region extent
// (not ±8 m — climate warp is fractional region units, not metres).
//
// Implementation: derive two hash values from the district position and
// map them to [-WARP_FRAC, +WARP_FRAC].
//
// D-010: the warp arithmetic is f64 positional math; the downstream blend
// weights are not used in any structural comparison.
const CLIMATE_WARP_FRAC: f64 = 0.25; // ±25% of a region's width/height.
let (warp_dx, warp_dy) = climate_edge_warp(world_seed, body_id, district_pos);
let warped_fx = (local_fx + warp_dx * CLIMATE_WARP_FRAC).clamp(0.0, 1.0);
let warped_fy = (local_fy + warp_dy * CLIMATE_WARP_FRAC).clamp(0.0, 1.0);
// ── Step 3: Identify four surrounding region positions ───────────────────
// The district's parent region.
let base_region = scale::district_to_region(district_pos);
// Determine which quadrant of the region the district falls in (after warp):
// if warped_fx > 0.5 the district is in the eastern half → blend with +X
// neighbour; else blend with X neighbour. Same for Y.
let (neighbour_dx, blend_tx) = if warped_fx >= 0.5 {
(1i32, (warped_fx - 0.5) * 2.0) // 0.0 at centre → 1.0 at +X edge
} else {
(-1i32, (0.5 - warped_fx) * 2.0) // 0.0 at centre → 1.0 at X edge
};
let (neighbour_dy, blend_ty) = if warped_fy >= 0.5 {
(1i32, (warped_fy - 0.5) * 2.0)
} else {
(-1i32, (0.5 - warped_fy) * 2.0)
};
// Four corner regions: (base, x-neighbour, y-neighbour, xy-neighbour).
let r00 = base_region;
let r10 = (base_region.0 + neighbour_dx, base_region.1);
let r01 = (base_region.0, base_region.1 + neighbour_dy);
let r11 = (base_region.0 + neighbour_dx, base_region.1 + neighbour_dy);
// ── Step 4: Fetch or derive the four region baselines ───────────────────
let baseline_for = |rpos: RegionPos| -> Option<f32> {
// Try the cache first.
if let Some(cache) = region_cache {
if let Some(rp) = cache.get(&rpos) {
return rp.clock.mean_temp_c;
}
}
// Derive on the fly (test path / cache miss).
let lat = region_centre_latitude_deg(rpos, body_params.body_radius_km);
let r_params = BodyParams {
district_latitude_deg: lat,
elevation_km: 0.0,
..body_params.clone()
};
derive_region_baseline_c(&r_params, constants, seed.seed())
};
let b00 = baseline_for(r00)?;
let b10 = baseline_for(r10)?;
let b01 = baseline_for(r01)?;
let b11 = baseline_for(r11)?;
// ── Step 5: Bilinear blend ───────────────────────────────────────────────
// Standard bilinear: tx blends X pairs, ty blends the Y result.
let tx = blend_tx as f32;
let ty = blend_ty as f32;
let top = b00 + (b10 - b00) * tx;
let bot = b01 + (b11 - b01) * tx;
Some(top + (bot - top) * ty)
}
/// Compute the climate edge-fuzz warp displacement for a district.
///
/// Returns `(warp_dx, warp_dy)` each in `[-1.0, +1.0]`, intended to be scaled
/// by `CLIMATE_WARP_FRAC` by the caller. Keyed on
/// `(world_seed, body_id, district_pos)` — same domain-separation conventions
/// as [`crate::atlas::domain_warp`], but using a distinct hash path so the
/// climate warp is never correlated with the terrain warp.
///
/// ## D-010 compliance
///
/// Pure integer hash, f64 mapping. No structural comparison.
fn climate_edge_warp(world_seed: u64, body_id: &str, district_pos: DistrictPos) -> (f64, f64) {
// Hash body_id into a u64 using FNV-1a (canonical per D-224).
let body_hash = crate::seed::fnv1a_64(body_id);
let base = world_seed
.wrapping_add(body_hash)
.wrapping_add(0x1234_5678_9abc_def0);
// Per-district position hash (zigzag + Cantor pairing — same as domain_warp).
let zz = |v: i32| -> u64 {
let v = v as i64;
((v << 1) ^ (v >> 63)) as u64
};
let x = zz(district_pos.0);
let y = zz(district_pos.1);
let s = x.wrapping_add(y);
let pos_id = s
.wrapping_mul(s.wrapping_add(1))
.wrapping_div(2)
.wrapping_add(y);
// Two independent streams: one for dx, one for dy.
let seed_x = splitmix64(base.wrapping_add(pos_id));
let seed_y = splitmix64(base.wrapping_add(pos_id).wrapping_add(0xdeadbeef_cafebabe));
let u64_to_unit = |h: u64| -> f64 {
let unit = (h >> 11) as f64 * (1.0 / (1u64 << 53) as f64);
(unit - 0.5) * 2.0 // [-1.0, +1.0)
};
(u64_to_unit(seed_x), u64_to_unit(seed_y))
}
// ---------------------------------------------------------------------------
// Region cache builder (for batch derivation)
// ---------------------------------------------------------------------------
/// Derive [`RegionProfile`]s for all regions that cover the set of districts
/// supplied, returning a `BTreeMap<RegionPos, RegionProfile>`.
///
/// Used when processing a batch of districts: call this first to populate the
/// region cache, then pass the cache into [`region_baseline_at_district`].
///
/// Pure and deterministic. Does not de-duplicate within this call — the caller
/// provides the unique set of region positions.
pub fn derive_regions_for_body(
seed: SeedChain,
body_params: &BodyParams,
constants: &ClimateConstants,
region_positions: impl IntoIterator<Item = RegionPos>,
) -> BTreeMap<RegionPos, RegionProfile> {
let mut out = BTreeMap::new();
for rpos in region_positions {
let profile = build_region_profile(seed, body_params, constants, rpos);
out.insert(rpos, profile);
}
out
}
// ---------------------------------------------------------------------------
// SeedDomain extension: RegionClimate = 11
// ---------------------------------------------------------------------------
//
// A new SeedDomain variant `RegionClimate = 11` is registered in
// `crate::seed::SeedDomain` to enable region-scoped seed derivation without
// colliding with other domains (D-224 domain separation). Because `SeedDomain`
// is in seed.rs (not this file), the variant is added there.
//
// This module uses the `seed.seed()` output for region baseline derivation
// (the same approach as `derive_temperature_c` uses `body_seed`), which is
// equivalent to calling `.derive(SeedDomain::Body, body_id_hash)` at the
// parent level and then reading the raw seed. No additional derivation level
// is needed unless per-region RNG streams are required (deferred to Q-105).
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::atlas::district_profile::{BodyParams, ClimateConstants};
use crate::seed::{SeedChain, SeedDomain};
fn body_seed() -> SeedChain {
SeedChain::root(42).derive(SeedDomain::Body, 1)
}
fn earth_params() -> BodyParams {
BodyParams {
hydrosphere: Some("ocean".into()),
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
body_radius_km: Some(6371.0),
..Default::default()
}
}
// ── RegionProfile builder ────────────────────────────────────────────────
#[test]
fn build_region_profile_is_deterministic() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = build_region_profile(body_seed(), &params, &constants, (5, 10));
let b = build_region_profile(body_seed(), &params, &constants, (5, 10));
assert_eq!(
a.clock.mean_temp_c, b.clock.mean_temp_c,
"region baseline must be deterministic"
);
assert_eq!(a.latitude_deg, b.latitude_deg);
assert_eq!(a.moisture_q, b.moisture_q);
}
#[test]
fn airless_body_has_no_region_baseline() {
let params = BodyParams {
atmosphere: Some("none".into()),
planet_class: Some("frozen".into()),
body_radius_km: Some(1500.0),
..Default::default()
};
let constants = ClimateConstants::default();
let rp = build_region_profile(body_seed(), &params, &constants, (0, 0));
assert_eq!(
rp.clock.mean_temp_c, None,
"airless body must have None region baseline"
);
}
#[test]
fn equatorial_region_is_warmer_than_polar() {
// With a body radius, north-pole region vs equatorial region.
let params = earth_params();
let constants = ClimateConstants::default();
// Region (0, 0) is near the north pole; (97, 15) is roughly equatorial.
let polar = build_region_profile(body_seed(), &params, &constants, (0, 0));
let equatorial = build_region_profile(body_seed(), &params, &constants, (97, 15));
match (polar.clock.mean_temp_c, equatorial.clock.mean_temp_c) {
(Some(p), Some(e)) => assert!(
p < e,
"polar baseline {p}°C must be colder than equatorial {e}°C"
),
_ => panic!("breathable body must have a temperature"),
}
}
#[test]
fn frozen_body_region_within_class_band() {
let params = BodyParams {
atmosphere: Some("thin".into()),
planet_class: Some("frozen".into()),
body_radius_km: Some(2000.0),
..Default::default()
};
let constants = ClimateConstants::default();
let (cold, warm) = constants.envelope("frozen");
for ry in [0i32, 5, 10] {
let rp = build_region_profile(body_seed(), &params, &constants, (0, ry));
let t = rp
.clock
.mean_temp_c
.expect("non-airless body must have temperature");
assert!(
t >= cold && t <= warm,
"frozen body region ({}, {ry}) baseline {t}°C outside band [{cold}, {warm}]",
0
);
}
}
#[test]
fn region_clock_default_state() {
let params = earth_params();
let constants = ClimateConstants::default();
let rp = build_region_profile(body_seed(), &params, &constants, (10, 10));
// Q-105 is deferred: mean state is Summer + Clear.
assert_eq!(rp.clock.season, SeasonPhase::Summer);
assert_eq!(rp.clock.weather, WeatherState::Clear);
}
// ── Baseline derivation ──────────────────────────────────────────────────
#[test]
fn region_baseline_no_elevation_lapse() {
// The region baseline must NOT include elevation lapse — that is a
// district-level modulation. Two regions at the same latitude but
// different positions should produce the same baseline if seed is the
// same (latitude is the only varying input here).
let constants = ClimateConstants::default();
let params = BodyParams {
atmosphere: Some("breathable".into()),
planet_class: Some("temperate".into()),
district_latitude_deg: 30.0,
elevation_km: 5.0, // This must be ignored by the region baseline
..Default::default()
};
// Explicitly pass elevation_km = 5.0; the baseline function overrides
// it to 0.0 internally.
let t_high_elev = derive_region_baseline_c(&params, &constants, 42);
let params_low = BodyParams {
elevation_km: 0.0,
..params.clone()
};
let t_low_elev = derive_region_baseline_c(&params_low, &constants, 42);
// Both must be equal — elevation is NOT a region-level input.
assert_eq!(
t_high_elev, t_low_elev,
"region baseline must be independent of elevation_km (lapse is district-level)"
);
}
// ── Edge-fuzz blend ──────────────────────────────────────────────────────
#[test]
fn edge_fuzz_is_deterministic() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = region_baseline_at_district(
42,
"TestBody",
(50, 75),
&params,
&constants,
body_seed(),
None,
);
let b = region_baseline_at_district(
42,
"TestBody",
(50, 75),
&params,
&constants,
body_seed(),
None,
);
assert_eq!(a, b, "edge-fuzz blend must be deterministic");
}
#[test]
fn edge_fuzz_within_class_band() {
let params = earth_params();
let constants = ClimateConstants::default();
let (cold, warm) = constants.envelope("temperate");
// Check several district positions.
for dx in [0i32, 50, 99, 100, 150] {
for dy in [0i32, 25, 50, 75] {
let t = region_baseline_at_district(
42,
"TestBody",
(dx, dy),
&params,
&constants,
body_seed(),
None,
)
.expect("breathable body must have a baseline");
assert!(
t >= cold && t <= warm,
"edge-fuzz baseline at ({dx},{dy}): {t}°C outside [{cold}, {warm}]"
);
}
}
}
#[test]
fn edge_fuzz_varies_with_body_id() {
let params = earth_params();
let constants = ClimateConstants::default();
let a = region_baseline_at_district(
42,
"BodyA",
(50, 50),
&params,
&constants,
body_seed(),
None,
);
let b = region_baseline_at_district(
42,
"BodyB",
(50, 50),
&params,
&constants,
body_seed(),
None,
);
// Same region pos but different body_id → different warp → different blend.
// Not guaranteed to differ (could accidentally hit same blend), but should
// for these inputs.
assert_ne!(
a, b,
"edge-fuzz blend should vary with body_id (different warp)"
);
}
#[test]
fn edge_fuzz_airless_returns_none() {
let params = BodyParams {
atmosphere: Some("none".into()),
planet_class: Some("frozen".into()),
..Default::default()
};
let constants = ClimateConstants::default();
let t = region_baseline_at_district(
42,
"AirlessBody",
(10, 10),
&params,
&constants,
body_seed(),
None,
);
assert_eq!(t, None, "airless body must return None from edge-fuzz");
}
// ── SeasonPhase / WeatherState discriminant pin ──────────────────────────
#[test]
fn season_phase_discriminants_pinned() {
// Append-only (D-010): renaming breaks serialised state.
assert_eq!(SeasonPhase::Summer as u8, 0);
assert_eq!(SeasonPhase::Autumn as u8, 1);
assert_eq!(SeasonPhase::Winter as u8, 2);
assert_eq!(SeasonPhase::Spring as u8, 3);
}
#[test]
fn weather_state_discriminants_pinned() {
assert_eq!(WeatherState::Clear as u8, 0);
assert_eq!(WeatherState::Overcast as u8, 1);
assert_eq!(WeatherState::Rain as u8, 2);
assert_eq!(WeatherState::Snow as u8, 3);
assert_eq!(WeatherState::Blizzard as u8, 4);
assert_eq!(WeatherState::DustStorm as u8, 5);
}
// ── Region cache batch builder ───────────────────────────────────────────
#[test]
fn derive_regions_for_body_covers_all_positions() {
let params = earth_params();
let constants = ClimateConstants::default();
let positions: Vec<RegionPos> = vec![(0, 0), (1, 0), (0, 1), (5, 5)];
let cache = derive_regions_for_body(body_seed(), &params, &constants, positions.clone());
assert_eq!(
cache.len(),
positions.len(),
"all positions must be present"
);
for pos in &positions {
assert!(
cache.contains_key(pos),
"region {pos:?} must be in the cache"
);
}
}
#[test]
fn edge_fuzz_cache_hit_matches_derived() {
// region_baseline_at_district with a cache should produce the same
// result as without (within the bilinear blend — the same regions are
// sampled in both paths).
let params = earth_params();
let constants = ClimateConstants::default();
let district_pos = (55i32, 20i32);
let base_region = scale::district_to_region(district_pos);
let positions: Vec<RegionPos> = vec![
base_region,
(base_region.0 + 1, base_region.1),
(base_region.0 - 1, base_region.1),
(base_region.0, base_region.1 + 1),
(base_region.0, base_region.1 - 1),
(base_region.0 + 1, base_region.1 + 1),
(base_region.0 - 1, base_region.1 + 1),
(base_region.0 + 1, base_region.1 - 1),
(base_region.0 - 1, base_region.1 - 1),
];
let cache = derive_regions_for_body(body_seed(), &params, &constants, positions);
let t_cached = region_baseline_at_district(
42,
"TestBody",
district_pos,
&params,
&constants,
body_seed(),
Some(&cache),
);
let t_derived = region_baseline_at_district(
42,
"TestBody",
district_pos,
&params,
&constants,
body_seed(),
None,
);
// Both paths should produce the same result (both use the same seed
// for on-demand derivation when the cache derives with the same seed).
assert_eq!(
t_cached, t_derived,
"cache hit and on-demand derivation must agree"
);
}
}