feat(simulation): cache TerrainAnalysis + derive basin_direction from the D8 thalweg (T-1044, T-1047)
T-1044: run_layer1 now returns TerrainAnalysis (carried transiently on CascadeSnapshot, dropped after the district + road-graph passes), eliminating the redundant per-body drainage::analyze + TerrainAnalysis::analyze re-run flagged by PERF/TODO(T-1044). Not persisted on the LRU-cached state (D-203/T-1048 size concern). T-1047: basin_direction is now derived from the real D8 thalweg. run_layer1 aggregates a per-district dominant D8 direction from the live fdir grid (carried transiently on DrainageResult), threaded via Layer1Output.district_basin_dirs -> derive_all_districts -> DistrictProfile.basin_direction; derive_chunk_context reads it directly. Removed the false derive_basin_direction (it branched on ocean_fraction_q then read seed bits despite a doc comment claiming an elev_q/slope_q D8 proxy) + corrected the module contract. D-239 §8 (D8 thalweg) now actually honoured. 1559 tests pass; golden byte-identical (district_basin_dirs is #[serde(skip)], transient). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::atlas::features::TerrainAnalysis;
|
||||
use crate::atlas::region_profile::{self, RegionProfile};
|
||||
use crate::atlas::scale::{self, RegionPos};
|
||||
use crate::atlas::scale::{self, BasinDirection, RegionPos};
|
||||
use crate::seed::SeedChain;
|
||||
use crate::simulation::generator::MorphologyZone;
|
||||
|
||||
@@ -232,6 +232,18 @@ pub struct DistrictProfile {
|
||||
/// Riparian variants override the base class in the 1–3 tile band along
|
||||
/// perennial waterways (D-239 §8 climate→vegetation law).
|
||||
pub vegetation_class: VegetationClass,
|
||||
|
||||
/// Dominant D8 thalweg direction for this district (T-1047, D-239 §8).
|
||||
///
|
||||
/// The **true** D8-computed dominant flow direction aggregated from the full
|
||||
/// `fdir` grid in `run_layer1` — not a seed-bit proxy. Threaded here from
|
||||
/// `Layer1Output.district_basin_dirs` so `derive_chunk_context` reads it
|
||||
/// directly instead of calling the formerly-false `derive_basin_direction`.
|
||||
///
|
||||
/// `#[serde(default)]` ensures backward compatibility when deserializing
|
||||
/// stored profiles that predate this field (T-1047).
|
||||
#[serde(default)]
|
||||
pub basin_direction: BasinDirection,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1007,6 +1019,7 @@ pub fn derive_district_profile(
|
||||
climate: &ClimateConstants,
|
||||
body_id: &str,
|
||||
region_cache: &BTreeMap<RegionPos, RegionProfile>,
|
||||
basin_direction: BasinDirection,
|
||||
) -> DistrictProfile {
|
||||
let (rx, ry) = pos;
|
||||
let w = ta.w;
|
||||
@@ -1068,6 +1081,7 @@ pub fn derive_district_profile(
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
region_baseline_c,
|
||||
basin_direction,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1100,6 +1114,7 @@ fn build_district_profile(
|
||||
elev_q: i32,
|
||||
ocean_fraction_q: i32,
|
||||
region_baseline_c: Option<f32>,
|
||||
basin_direction: BasinDirection,
|
||||
) -> DistrictProfile {
|
||||
let tectonic_class = derive_tectonic_class(body_params);
|
||||
|
||||
@@ -1162,6 +1177,7 @@ fn build_district_profile(
|
||||
temperature_c,
|
||||
moisture_q,
|
||||
vegetation_class,
|
||||
basin_direction,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1271,6 +1287,9 @@ pub fn derive_district(
|
||||
None, // no pre-built cache; derive on-the-fly
|
||||
);
|
||||
|
||||
// derive_district is the on-demand path (arbitrary DistrictPos, no L1 working
|
||||
// grid); basin_direction defaults to North here. The batch path
|
||||
// (derive_all_districts) threads the true D8 thalweg direction from L1.
|
||||
build_district_profile(
|
||||
seed,
|
||||
¶ms,
|
||||
@@ -1279,6 +1298,7 @@ pub fn derive_district(
|
||||
elev_q,
|
||||
ocean_fraction_q,
|
||||
region_baseline_c,
|
||||
BasinDirection::default(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1353,12 +1373,18 @@ fn bilinear_bool(mask: &[bool], w: usize, h: usize, px: f64, py: f64) -> f32 {
|
||||
///
|
||||
/// `body_id` is the body's string identifier, required for the climate edge-fuzz
|
||||
/// warp domain separation.
|
||||
/// `basin_dirs` is the per-district dominant D8 thalweg direction computed in
|
||||
/// `run_layer1` (T-1047). When `Some`, each district's `basin_direction` is read
|
||||
/// from the map; missing entries (edge districts with no land cells) default to
|
||||
/// `BasinDirection::North`. When `None` (tests / paths before Layer 1 runs),
|
||||
/// every district gets `BasinDirection::North`.
|
||||
pub fn derive_all_districts(
|
||||
seed: SeedChain,
|
||||
body_params: &BodyParams,
|
||||
ta: &TerrainAnalysis,
|
||||
grid_cells_per_district: usize,
|
||||
body_id: &str,
|
||||
basin_dirs: Option<&BTreeMap<DistrictPos, BasinDirection>>,
|
||||
) -> BTreeMap<DistrictPos, DistrictProfile> {
|
||||
let climate = ClimateConstants::default();
|
||||
let gcpr = grid_cells_per_district.max(1);
|
||||
@@ -1409,6 +1435,9 @@ pub fn derive_all_districts(
|
||||
latitude_deg: lat_deg,
|
||||
..body_params.clone()
|
||||
};
|
||||
let basin_direction = basin_dirs
|
||||
.and_then(|m| m.get(&pos).copied())
|
||||
.unwrap_or_default();
|
||||
let profile = derive_district_profile(
|
||||
seed,
|
||||
&district_params,
|
||||
@@ -1418,6 +1447,7 @@ pub fn derive_all_districts(
|
||||
&climate,
|
||||
body_id,
|
||||
®ion_cache,
|
||||
basin_direction,
|
||||
);
|
||||
out.insert(pos, profile);
|
||||
}
|
||||
@@ -1469,7 +1499,7 @@ mod tests {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let params = BodyParams::default();
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body");
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None);
|
||||
|
||||
// Expected: ceil(64/8) × ceil(32/8) = 8 × 4 = 32 districts.
|
||||
assert_eq!(districts.len(), 32, "district count mismatch");
|
||||
@@ -1593,6 +1623,7 @@ mod tests {
|
||||
&climate,
|
||||
"test_body",
|
||||
&BTreeMap::new(),
|
||||
BasinDirection::North,
|
||||
);
|
||||
let p2 = derive_district_profile(
|
||||
test_seed(),
|
||||
@@ -1603,6 +1634,7 @@ mod tests {
|
||||
&climate,
|
||||
"test_body",
|
||||
&BTreeMap::new(),
|
||||
BasinDirection::North,
|
||||
);
|
||||
// Equality via serialized fields (no PartialEq on MorphologyZone — compare by name).
|
||||
assert_eq!(
|
||||
@@ -1697,7 +1729,7 @@ mod tests {
|
||||
let hm = test_hm();
|
||||
let ta = test_ta(&hm);
|
||||
let params = BodyParams::default();
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body");
|
||||
let districts = derive_all_districts(test_seed(), ¶ms, &ta, 8, "test_body", None);
|
||||
// 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");
|
||||
|
||||
Reference in New Issue
Block a user