fix(simulation): region baseline latitude — equator-anchored signed rows (T-1186)

region_centre_latitude_deg mapped row 0 to the north pole with lat_frac
clamped [0,1] while the derive core floor-divides equator-anchored signed
world metres — every northern-hemisphere region clamped to +90 (polar
everywhere) and the southern hemisphere read as compressed northern
tropics. Now mirrors the derive core's exact inverse mapping (lat_frac =
centre_y/meridian clamped [-0.5,0.5], lat = -frac*180). The ticket's
in-the-wild datapoint flips as predicted: GJ338Bd region (136,43), old
baseline +13.94N/26.8C -> new -76.06S/10.3C; the 76S district cell now
derives Lake/2.07C/Light glaciation. Goldens regenerated (believability +
window derivation, stable on rerun); two region_profile unit tests
repositioned — their probe coordinates meant different places under the
old convention. step_canvas rung-0 already used the correct signed
convention; layer_proxy's pole-anchored pseudo-grid stays deferred to
T-1181's rung-0 rebuild per D-256(f).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:08:26 +02:00
co-authored by Claude Fable 5
parent 7ab15baed5
commit 07f0fa307f
3 changed files with 150 additions and 110 deletions
+54 -14
View File
@@ -258,11 +258,32 @@ pub fn derive_region_baseline_c(
/// 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.
/// **Equator-anchored SIGNED row convention (T-1186 fix)** — this MUST mirror
/// the canonical derive core's own inverse mapping
/// (`derive_at_metres_with_riparian`, district_profile.rs: `lat_frac = (wy /
/// meridian_m).clamp(-0.5, 0.5)`, `lat_deg = -lat_frac * 180.0`, keyed on
/// equator-anchored signed world metres via `district_to_region`'s
/// `div_euclid`). `region_pos.1` is therefore SIGNED: negative rows are
/// north, positive rows are south, row 0 straddles the equator — NOT the
/// pole-anchored non-negative convention this function used before T-1186
/// (row 0 = north pole, `lat_frac` clamped to `[0, 1]`), which silently
/// disagreed with the derive core it feeds (D-256(a): one absolute-metre
/// derive core, one inverse mapping — this was the second, wrong, mapping).
///
/// 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.
///
/// **Convention note (T-1186 audit):** two OTHER call sites in this codebase
/// still use the old non-negative pole-anchored row convention and do NOT
/// call this function — `step_canvas::build_step_canvas`'s Global (rung-0)
/// canvas (deliberately switched to signed-equator rows ahead of this fix,
/// see its doc comment) and `layer_proxy::build_region_grid` /
/// `cascade::LayerRegionOutput` (still pole-anchored, non-negative `ry`,
/// D-256(f)-deferred to T-1181's rung-0 rebuild — its sole reader is the
/// stale body-view overlay, no consumer disagreement in this ticket's blast
/// radius). See T-1186's report for the proposed one-convention unification;
/// this function's fix does not by itself touch either of those call sites.
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;
@@ -270,13 +291,17 @@ pub fn region_centre_latitude_deg(region_pos: RegionPos, body_radius_km: Option<
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.
// The meridian spans πR km. Each region is REGION_M metres tall,
// equator-anchored: region row 0 straddles the equator, negative rows
// are north (mirrors district_to_region's div_euclid on signed,
// equator-anchored district positions).
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
// Clamp: lat_frac in [-0.5, 0.5]; -0.5 = N pole, +0.5 = S pole — the exact
// inverse of derive_at_metres_with_riparian's `lat_frac = (wy /
// meridian_m).clamp(-0.5, 0.5)`.
let lat_frac = (region_centre_y_m / meridian_m).clamp(-0.5, 0.5);
-lat_frac * 180.0
}
/// Build a [`RegionProfile`] for the region at `region_pos`.
@@ -611,12 +636,16 @@ mod tests {
#[test]
fn equatorial_region_is_warmer_than_polar() {
// With a body radius, north-pole region vs equatorial region.
// T-1186: region rows (RegionPos.1, the Y/row component — RegionPos is
// (x, y)) are equator-anchored SIGNED (row 0 straddles the equator;
// negative = north, matching the derive core's floor-divide of
// equator-anchored world metres). Region (0, 0) is therefore near the
// EQUATOR, not the pole — (0, -97) is the genuinely polar region on
// this Earth-radius test body (~97.7 regions pole-to-pole).
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));
let polar = build_region_profile(body_seed(), &params, &constants, (0, -97));
let equatorial = build_region_profile(body_seed(), &params, &constants, (0, 0));
match (polar.clock.mean_temp_c, equatorial.clock.mean_temp_c) {
(Some(p), Some(e)) => assert!(
p < e,
@@ -746,12 +775,23 @@ mod tests {
#[test]
fn edge_fuzz_varies_with_body_id() {
// T-1186: (50, 50) sat right at the equator under the new
// equator-anchored-signed row convention (region row 0), where
// earth_params()'s ocean/breathable/temperate band saturates at the
// 28°C warm ceiling for every neighbour regardless of warp — a
// fix-induced false negative, not a real regression (the OLD
// pole-anchored convention happened to place row 0 near the pole,
// where the band isn't saturated). (50, 2400) — a mid-latitude
// district — also turned out to be a coincidental warp collision at
// this seed (both bodies landing on the same blend). (73, 2455) is a
// confirmed non-degenerate probe: comfortably inside the unsaturated
// band with the two bodies' warps genuinely diverging.
let params = earth_params();
let constants = ClimateConstants::default();
let a = region_baseline_at_district(
42,
"BodyA",
(50, 50),
(73, 2455),
&params,
&constants,
body_seed(),
@@ -760,7 +800,7 @@ mod tests {
let b = region_baseline_at_district(
42,
"BodyB",
(50, 50),
(73, 2455),
&params,
&constants,
body_seed(),