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 (0–100 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, 0–100) 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 (0–100 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"
);
}
}