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:
@@ -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, ¶ms, 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,
|
||||
¶ms,
|
||||
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, ¶ms, &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, ¶ms, &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), ¶ms, &climate, 0.0);
|
||||
let t_cool_region = derive_district_temperature_c(Some(5.0), ¶ms, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user