@@ -23,7 +23,8 @@ use std::collections::BTreeMap;
use serde ::{ Deserialize , Serialize } ;
use crate ::atlas ::features ::TerrainAnalysis ;
use crate ::atlas ::scale ;
use crate ::atlas ::region_profile ::{ self , RegionProfile } ;
use crate ::atlas ::scale ::{ self , RegionPos } ;
use crate ::seed ::SeedChain ;
use crate ::simulation ::generator ::MorphologyZone ;
@@ -148,9 +149,17 @@ pub struct BodyParams {
/// "stable" | "active" | "volcanic" | "tidally_forced". If absent, derived
/// from `planet_class`.
pub tectonic_activity : Option < String > ,
/// Latitude of the district 's centre in the body's reference frame, in degrees.
/// Latitude of this cell 's centre in the body's reference frame, in degrees.
/// 0.0 = equator, ±90.0 = poles. Used for latitude-band temperature gradient.
pub district_latitude_deg : f64 ,
///
/// This field serves at **both** the district (2 km) and region (~205 km) scales:
/// when building a `DistrictProfile` it holds the district centre latitude; when
/// passed to [`crate::atlas::region_profile::derive_region_baseline_c`] it is
/// expected to carry the **region centre latitude** (callers override it via
/// struct-update syntax before passing the params down). The name was changed
/// from `latitude_deg` to `latitude_deg` (T-1078) to remove the
/// misleading scale implication.
pub latitude_deg : f64 ,
/// Mean elevation of this district relative to sea level, in km. Used for lapse rate.
pub elevation_km : f64 ,
/// `bodies.body_radius_km` (D-204) — the body's radius in km. The single
@@ -813,7 +822,7 @@ pub fn derive_temperature_c(
let maritime = constants . maritime_factor ( hydrosphere ) ;
let mid = ( cold + warm ) * 0.5 ;
let half = band_width * 0.5 * maritime ;
let lat_frac = ( params . district_latitude_deg . abs ( ) as f32 / 90.0 ) . clamp ( 0.0 , 1.0 ) ;
let lat_frac = ( params . 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 ;
@@ -975,9 +984,20 @@ pub fn derive_moisture_q(params: &BodyParams) -> i32 {
/// cell; default is 8 (at 128× 64 working grid, that yields ~80× 64 districts ≈
/// ~5 000 districts/body, within the D-203 ~6 000/body budget).
///
/// Temperature and moisture are derived inline via D-239 §2 / D-240 climate
/// functions (T-1024). Pass a `&ClimateConstants` to control the tuning constants.
/// The seed chain provides the body-scoped seed for the D-240 temperature nudge.
/// Temperature and moisture are derived via the D-243 §3/§4 two-phase stack:
/// the edge-fuzz-blended region baseline (from `region_cache` / on-the-fly
/// derivation via [`region_profile::region_baseline_at_district`]) feeds the
/// district modulation ([`derive_district_temperature_c`]). Pass a
/// `&ClimateConstants` to control the tuning constants. The seed chain provides
/// the body-scoped seed.
///
/// ## Parameters
///
/// - `body_id` — the body's string identifier; required for the D-243 §4 climate
/// edge-fuzz warp domain separation (distinct bodies get distinct warps).
/// - `region_cache` — pre-computed [`RegionProfile`] map keyed by [`RegionPos`];
/// if a neighbour region is missing it is derived on the fly. Build with
/// [`region_profile::derive_regions_for_body`] before calling this in a loop.
pub fn derive_district_profile (
seed : SeedChain ,
body_params : & BodyParams ,
@@ -985,6 +1005,8 @@ pub fn derive_district_profile(
pos : DistrictPos ,
grid_cells_per_district : usize ,
climate : & ClimateConstants ,
body_id : & str ,
region_cache : & BTreeMap < RegionPos , RegionProfile > ,
) -> DistrictProfile {
let ( rx , ry ) = pos ;
let w = ta . w ;
@@ -1025,9 +1047,19 @@ pub fn derive_district_profile(
( 0 , 0 , 0 )
} ;
// No region layer in the ea ger 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) .
// D-243 §3/§4: compute the ed ge-fuzz-blended region baseline for this district,
// then pass it through build_district_profile so the two-phase derivation path runs.
// The warp uses `seed.seed()` (the body-scoped seed) for domain separation .
let region_baseline_c = region_profile ::region_baseline_at_district (
seed . seed ( ) ,
body_id ,
pos ,
body_params ,
climate ,
seed ,
Some ( region_cache ) ,
) ;
build_district_profile (
seed ,
body_params ,
@@ -1035,7 +1067,7 @@ pub fn derive_district_profile(
slope_q ,
elev_q ,
ocean_fraction_q ,
None , // region_baseline_c: legacy path
region_baseline_c ,
)
}
@@ -1072,7 +1104,7 @@ fn build_district_profile(
let tectonic_class = derive_tectonic_class ( body_params ) ;
// 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
// elev_q (0– 100 scaled to the body's elevation span). 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 ,
@@ -1143,10 +1175,22 @@ fn build_district_profile(
/// to direct heightmap indexing.
///
/// Pure and deterministic (D-227/D-010): a function of
/// `(seed, body_params, terrain, district_pos)`; the f64 scatter is quantised to
/// integer `slope_q`/`elev_q` at the decision boundary.
/// `(seed, body_id, body_params, terrain, district_pos)`; the f64 scatter is
/// quantised to integer `slope_q`/`elev_q` at the decision boundary.
///
/// ## Region baseline (D-243 §3/§4, T-1078)
///
/// The district temperature is derived as a **modulation** of the edge-fuzz-blended
/// region baseline ([`region_profile::region_baseline_at_district`]). No pre-built
/// region cache is required here — the on-demand path derives the four surrounding
/// region baselines on-the-fly (pure, deterministic, cheap: four `derive_region_baseline_c`
/// calls). For batch derivation of many districts use [`derive_all_districts`], which
/// builds a region cache once per body.
///
/// `body_id` is required for the D-243 §4 climate edge-fuzz warp domain separation.
pub fn derive_district (
seed : SeedChain ,
body_id : & str ,
body_params : & BodyParams ,
ta : & TerrainAnalysis ,
district_pos : DistrictPos ,
@@ -1209,16 +1253,24 @@ pub fn derive_district(
let ocean_fraction_q = ( ( ocean_frac * 100.0 ) . round ( ) as i32 ) . clamp ( 0 , 100 ) ;
let params = BodyParams {
district_latitude_deg : lat_deg ,
latitude_deg : lat_deg ,
.. body_params . clone ( )
} ;
// 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.
// D-243 §3/§4: compute the edge-fuzz-blended region baseline on-the-fly for
// this district. No pre-built cache here — the on-demand path derives the four
// surrounding region baselines directly. Pure, deterministic, cheap .
// `seed.seed()` (the body-scoped seed value) ensures body-unique warp separation.
let region_baseline_c = region_profile ::region_baseline_at_district (
seed . seed ( ) ,
body_id ,
district_pos ,
& params ,
climate ,
seed ,
None , // no pre-built cache; derive on-the-fly
) ;
build_district_profile (
seed ,
& params ,
@@ -1226,7 +1278,7 @@ pub fn derive_district(
slope_q ,
elev_q ,
ocean_fraction_q ,
None ,
region_baseline_c ,
)
}
@@ -1291,17 +1343,53 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
/// District latitude is derived from the row index: ry=0 maps to the north pole
/// (+90°), ry=district_rows-1 maps to the south pole (-90°). This is a linear
/// mapping across the equirectangular heightmap.
///
/// ## Region cache (D-243 §3/§4, T-1078)
///
/// A [`RegionProfile`] cache is built once per body from all region positions
/// that cover the district grid, then passed to each [`derive_district_profile`]
/// call so the D-243 §4 edge-fuzz blend reads a consistent set of region
/// baselines — all four corners of every blend come from the same derived set.
///
/// `body_id` is the body's string identifier, required for the climate edge-fuzz
/// warp domain separation.
pub fn derive_all_districts (
seed : SeedChain ,
body_params : & BodyParams ,
ta : & TerrainAnalysis ,
grid_cells_per_district : usize ,
body_id : & str ,
) -> BTreeMap < DistrictPos , DistrictProfile > {
let climate = ClimateConstants ::default ( ) ;
let gcpr = grid_cells_per_district . max ( 1 ) ;
let district_cols = ta . w . div_ceil ( gcpr ) as i32 ;
let district_rows = ta . h . div_ceil ( gcpr ) as i32 ;
// Build the region cache once for the whole body before district derivation.
// Collect all unique region positions that cover this district grid, plus
// their immediate neighbours (the edge-fuzz blend samples up to one region
// beyond the district's own region). Using a BTreeSet for determinism (D-010).
let region_positions : std ::collections ::BTreeSet < RegionPos > = {
let mut set = std ::collections ::BTreeSet ::new ( ) ;
for ry in 0 .. district_rows {
for rx in 0 .. district_cols {
let district_pos = ( rx , ry ) ;
let rpos = scale ::district_to_region ( district_pos ) ;
// The edge-fuzz blend samples the base region and one neighbour
// in each axis direction (±1). Pre-populate all 9 candidates so
// cache hits dominate and on-the-fly derivations are rare.
for dy in - 1 i32 ..= 1 {
for dx in - 1 i32 ..= 1 {
set . insert ( ( rpos . 0 + dx , rpos . 1 + dy ) ) ;
}
}
}
}
set
} ;
let region_cache =
region_profile ::derive_regions_for_body ( seed , body_params , & climate , region_positions ) ;
let mut out = BTreeMap ::new ( ) ;
for ry in 0 .. district_rows {
// Map ry to latitude: row 0 → +90°, row (rows-1) → -90°.
@@ -1318,10 +1406,19 @@ pub fn derive_all_districts(
// deriving elevation_km from the district's own elev_q (not the caller's
// body_params.elevation_km). Per-cell refinement happens at ChunkContext (D-239).
let district_params = BodyParams {
district_latitude_deg : lat_deg ,
latitude_deg : lat_deg ,
.. body_params . clone ( )
} ;
let profile = derive_district_profile ( seed , & district_params , ta , pos , gcpr , & climate ) ;
let profile = derive_district_profile (
seed ,
& district_params ,
ta ,
pos ,
gcpr ,
& climate ,
body_id ,
& region_cache ,
) ;
out . insert ( pos , profile ) ;
}
}
@@ -1372,7 +1469,7 @@ mod tests {
let hm = test_hm ( ) ;
let ta = test_ta ( & hm ) ;
let params = BodyParams ::default ( ) ;
let districts = derive_all_districts ( test_seed ( ) , & params , & ta , 8 ) ;
let districts = derive_all_districts ( test_seed ( ) , & params , & ta , 8 , " test_body " ) ;
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
assert_eq! ( districts . len ( ) , 32 , " district count mismatch " ) ;
@@ -1396,8 +1493,8 @@ mod tests {
let ta = test_ta ( & hm ) ;
let climate = ClimateConstants ::default ( ) ;
let p = earth_params ( ) ;
let a = derive_district ( test_seed ( ) , & p , & ta , ( 1234 , - 567 ) , & climate ) ;
let b = derive_district ( test_seed ( ) , & p , & ta , ( 1234 , - 567 ) , & climate ) ;
let a = derive_district ( test_seed ( ) , " test_body " , & p , & ta , ( 1234 , - 567 ) , & climate ) ;
let b = derive_district ( test_seed ( ) , " test_body " , & p , & ta , ( 1234 , - 567 ) , & climate ) ;
assert_eq! ( a . elev_q , b . elev_q ) ;
assert_eq! ( a . slope_q , b . slope_q ) ;
assert_eq! ( a . morphology_zone , b . morphology_zone ) ;
@@ -1415,9 +1512,15 @@ mod tests {
// meridian ≈ π·6371·1000 m; a district near the pole is ~quarter-meridian away.
let merid_districts =
( std ::f64 ::consts ::PI * 6371.0 * 1000.0 / scale ::DISTRICT_M as f64 ) as i32 ;
let equator = derive_district ( test_seed ( ) , & p , & ta , ( 0 , 0 ) , & climate ) ;
let high_lat =
derive_district ( test_seed ( ) , & p , & ta , ( 0 , merid_districts / 2 - 2 ) , & climate ) ;
let equator = derive_district ( test_seed ( ) , " test_body " , & p , & ta , ( 0 , 0 ) , & climate ) ;
let high_lat = derive_district (
test_seed ( ) ,
" test_body " ,
& p ,
& ta ,
( 0 , merid_districts / 2 - 2 ) ,
& climate ,
) ;
match ( equator . temperature_c , high_lat . temperature_c ) {
( Some ( eq ) , Some ( hi ) ) = > {
assert! ( hi < eq , " near-pole district must be colder ({hi} !< {eq}) " )
@@ -1441,7 +1544,7 @@ mod tests {
let ta = test_ta ( & flat ) ;
let climate = ClimateConstants ::default ( ) ;
let p = earth_params ( ) ;
let d = derive_district ( test_seed ( ) , & p , & ta , ( 500 , 100 ) , & climate ) ;
let d = derive_district ( test_seed ( ) , " flat " , & p , & ta , ( 500 , 100 ) , & climate ) ;
// Flat land everywhere → slope 0 → no invented relief, no ocean.
assert_eq! (
d . slope_q , 0 ,
@@ -1462,8 +1565,8 @@ mod tests {
atmosphere : Some ( " breathable " . into ( ) ) ,
.. Default ::default ( ) // body_radius_km: None
} ;
let a = derive_district ( test_seed ( ) , & p , & ta , ( 20 , 10 ) , & climate ) ;
let b = derive_district ( test_seed ( ) , & p , & ta , ( 20 , 10 ) , & climate ) ;
let a = derive_district ( test_seed ( ) , " test_body " , & p , & ta , ( 20 , 10 ) , & climate ) ;
let b = derive_district ( test_seed ( ) , " test_body " , & p , & ta , ( 20 , 10 ) , & climate ) ;
assert_eq! ( a . elev_q , b . elev_q ) ;
assert! ( ( 0 ..= 100 ) . contains ( & a . elev_q ) & & ( 0 ..= 100 ) . contains ( & a . slope_q ) ) ;
}
@@ -1481,8 +1584,26 @@ mod tests {
} ;
let pos = ( 2 , 1 ) ;
let climate = ClimateConstants ::default ( ) ;
let p1 = derive_district_profile ( test_seed ( ) , & params , & ta , pos , 8 , & climate ) ;
let p2 = derive_district_profile ( test_seed ( ) , & params , & ta , pos , 8 , & climate ) ;
let p1 = derive_district_profile (
test_seed ( ) ,
& params ,
& ta ,
pos ,
8 ,
& climate ,
" test_body " ,
& BTreeMap ::new ( ) ,
) ;
let p2 = derive_district_profile (
test_seed ( ) ,
& params ,
& ta ,
pos ,
8 ,
& climate ,
" test_body " ,
& BTreeMap ::new ( ) ,
) ;
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
assert_eq! (
format! ( " {:?} " , p1 . morphology_zone ) ,
@@ -1576,7 +1697,7 @@ mod tests {
let hm = test_hm ( ) ;
let ta = test_ta ( & hm ) ;
let params = BodyParams ::default ( ) ;
let districts = derive_all_districts ( test_seed ( ) , & params , & ta , 8 ) ;
let districts = derive_all_districts ( test_seed ( ) , & params , & ta , 8 , " test_body " ) ;
// BTreeMap iterates in sorted key order — verify the first key is (0,0).
let first = districts . keys ( ) . next ( ) . expect ( " at least one district " ) ;
assert_eq! ( * first , ( 0 , 0 ) , " first district must be at origin " ) ;
@@ -1607,7 +1728,7 @@ mod tests {
let params = BodyParams {
planet_class : Some ( planet_class . into ( ) ) ,
atmosphere : Some ( atmosphere . into ( ) ) ,
district_latitude_deg : lat ,
latitude_deg : lat ,
elevation_km : elev_km ,
.. Default ::default ( )
} ;
@@ -1683,7 +1804,7 @@ mod tests {
let params = BodyParams {
atmosphere : Some ( " thin " . into ( ) ) ,
planet_class : Some ( " arid " . into ( ) ) ,
district_latitude_deg : 45.0 ,
latitude_deg : 45.0 ,
elevation_km : 1.5 ,
.. Default ::default ( )
} ;
@@ -1700,7 +1821,7 @@ mod tests {
let params = BodyParams {
atmosphere : Some ( " breathable " . into ( ) ) ,
planet_class : Some ( " temperate " . into ( ) ) ,
district_latitude_deg : 30.0 ,
latitude_deg : 30.0 ,
elevation_km : 0.0 ,
.. Default ::default ( )
} ;
@@ -1755,7 +1876,7 @@ mod tests {
planet_class : Some ( ( * class ) . into ( ) ) ,
atmosphere : Some ( ( * atmo ) . into ( ) ) ,
hydrosphere : Some ( hydro . into ( ) ) ,
district_latitude_deg : lat ,
latitude_deg : lat ,
elevation_km : elev ,
.. Default ::default ( )
} ;
@@ -1784,7 +1905,7 @@ mod tests {
planet_class : Some ( " temperate " . into ( ) ) ,
atmosphere : Some ( " standard " . into ( ) ) ,
hydrosphere : Some ( hydro . into ( ) ) ,
district_latitude_deg : lat ,
latitude_deg : lat ,
elevation_km : 0.0 ,
.. Default ::default ( )
} ;
@@ -1810,7 +1931,7 @@ mod tests {
let params = BodyParams {
planet_class : Some ( " unknown_alien_class " . into ( ) ) ,
atmosphere : Some ( " breathable " . into ( ) ) ,
district_latitude_deg : 0.0 ,
latitude_deg : 0.0 ,
elevation_km : 0.0 ,
.. Default ::default ( )
} ;
@@ -2643,7 +2764,7 @@ mod tests {
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
latitude_deg : 0.0 , // this should be irrelevant for the modulation
.. Default ::default ( )
} ;