fix(simulation): lake_margin_q normalizes per basin — full-range depth gradient (T-1188)
The PR #206 eyeball caught what every numeric gate passed: the depth signal was visually flat on both test lakes (GJ1c lmq=0 across the whole basin; GJ338Bd 0-13 of 100). Two compounding causes: a fixed absolute ceiling (one body's p90 cell depth) compressing skewed depth distributions into single digits, and heightmap-pitch depth variation being sub-texel-tiny within most basins. lake_margin_q is now depth / the basin's own maximum settled depth: HydrologyResult grows basin_max_depth_scaled (computed in solve() from existing basin_cells membership, broadcast per basin), threaded through HydrologySample.basin_max_depth, normalized in lake_from_hydrology_at with a degenerate-basin epsilon guard (a genuinely uniform pond shades flat — honest, not forced). Lake EXISTENCE (filled > original) is untouched — only tone changes. Measured at district spacing: GJ1c min=0 p50=33 max=84; GJ338Bd min=8 p50=38 max=70 — full-range shore-to-deep ramps on both. No perceptual curve added: the linear per-basin ramp is already well-quartiled. project.yaml 0.4.1 -> 0.4.2 (0.4.1-tagged canvases carrying flat-lmq semantics reached real disk caches during eyeball runs and must miss). Acceptance gates green; zero golden churn (lake_margin_q not captured by either golden shape). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -229,18 +229,23 @@ pub struct DistrictProfile {
|
||||
/// Settled-hydrology lake-margin depth band (T-1188, D-227 amendment (4)
|
||||
/// continued): `0` at/near the shoreline (the `filled == elevation`
|
||||
/// crossing `lake_from_hydrology_at` gates on), ramping toward `100` as
|
||||
/// the settled water surface sits deeper above the original bedrock —
|
||||
/// quantized `((filled - elevation) / LAKE_MARGIN_DEPTH_CEILING * 100)`,
|
||||
/// clamped. `0` for every non-lake cell (never negative — a cell with no
|
||||
/// settled water above it has no margin to shade). This is the lake
|
||||
/// counterpart to `ocean_fraction_q`'s coastal transition-zone gradient:
|
||||
/// `ocean_fraction_q` is always `0` inside a lake basin (lakes sit ABOVE
|
||||
/// sea level; `ta.ocean_mask` never fires there), so the existing
|
||||
/// TidalFlat/DuneStrand/CliffCoast/Estuarine morphology gates — every one
|
||||
/// keyed on `ocean_fraction_q` — are structurally unreachable at a lake
|
||||
/// edge. `lake_margin_q` gives the client a continuous tone source for
|
||||
/// lake shorelines without inventing a second morphology-classification
|
||||
/// path; see `derive_lake_margin_q`'s doc for the full rationale.
|
||||
/// the settled water surface sits deeper above the original bedrock,
|
||||
/// relative to THIS BASIN's own maximum depth — quantized
|
||||
/// `((filled - elevation) / basin_max_depth * 100)`, clamped, `0` when
|
||||
/// the basin's own max depth is degenerate (see
|
||||
/// `LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`). `0` for every non-lake cell
|
||||
/// (never negative — a cell with no settled water above it has no
|
||||
/// margin to shade). This is the lake counterpart to `ocean_fraction_q`'s
|
||||
/// coastal transition-zone gradient: `ocean_fraction_q` is always `0`
|
||||
/// inside a lake basin (lakes sit ABOVE sea level; `ta.ocean_mask` never
|
||||
/// fires there), so the existing TidalFlat/DuneStrand/CliffCoast/
|
||||
/// Estuarine morphology gates — every one keyed on `ocean_fraction_q` —
|
||||
/// are structurally unreachable at a lake edge. `lake_margin_q` gives
|
||||
/// the client a continuous tone source for lake shorelines without
|
||||
/// inventing a second morphology-classification path; see
|
||||
/// `lake_from_hydrology_at`'s doc for the full per-basin-normalization
|
||||
/// rationale (PR #206 eyeball finding: a fixed absolute ceiling read
|
||||
/// visually flat on real lakes).
|
||||
#[serde(default)]
|
||||
pub lake_margin_q: i32,
|
||||
|
||||
@@ -2101,21 +2106,17 @@ pub(crate) fn bilinear(field: &[f32], w: usize, h: usize, px: f64, py: f64) -> f
|
||||
a + (b - a) * ty
|
||||
}
|
||||
|
||||
/// Saturation ceiling for [`lake_from_hydrology_at`]'s depth-band quantization,
|
||||
/// in the SAME `[0.0, 1.0]` normalized elevation-fraction units as
|
||||
/// `HydrologySample.filled`/`elevation` (NOT metres — no per-body elevation
|
||||
/// span is threaded to this call site, matching `ocean_fraction_q`'s own
|
||||
/// fraction-domain quantization one scope up). T-1188 calibration (GJ338Bd's
|
||||
/// `wiki`-committed lake, `believability-v1` seed): sampled 5,043 flooded
|
||||
/// working-grid cells, depth (`filled - elevation`) p50 ≈ 0.0083, p90 ≈
|
||||
/// 0.058, max ≈ 0.116 — this ceiling sits just above the observed p90 so
|
||||
/// most real basins use the full 0–100 range instead of clipping early
|
||||
/// (a shallow margin near the shore reads near-0, a basin's deep centre
|
||||
/// saturates to 100 — exactly the "deep water reads darker/richer" reading
|
||||
/// the ocean-side `ocean_fraction_q >= 80` OpenOcean floor already assumes).
|
||||
/// A tuning constant, not a measured physical limit — revisit if a
|
||||
/// lore-anchored body ships a dramatically deeper basin.
|
||||
const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
|
||||
/// Degenerate-basin guard for [`lake_from_hydrology_at`]'s per-basin
|
||||
/// normalization: a basin whose own max depth is at or below this floor
|
||||
/// (in the same `[0.0, 1.0]` normalized elevation-fraction units as
|
||||
/// `HydrologySample`) is treated as uniformly shallow — `lake_margin_q`
|
||||
/// reads `0` everywhere in it rather than dividing by a near-zero
|
||||
/// denominator (which would amplify heightmap sampling noise into an
|
||||
/// artificial, meaningless gradient). `1e-5` is ~17× smaller than the
|
||||
/// smallest genuinely-flooded per-cell depth observed in the T-1188
|
||||
/// calibration survey (below), well inside "this basin has no real depth
|
||||
/// signal at this heightmap resolution" territory.
|
||||
const LAKE_MARGIN_DEGENERATE_BASIN_EPSILON: f32 = 1e-5;
|
||||
|
||||
/// The T-1184 settled-hydrology lake test (D-227 amendment (4) / D-255(f)
|
||||
/// mechanism B) PLUS its T-1188 depth-band extension. Returns
|
||||
@@ -2132,9 +2133,30 @@ const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
|
||||
/// here as "fall through to the `ocean_fraction_q` heuristic", never as an
|
||||
/// error).
|
||||
/// - `lake_margin_q` — `0` when `!is_lake` (a non-lake cell has no margin to
|
||||
/// shade); otherwise the settled depth `(filled - original)`, quantized
|
||||
/// against [`LAKE_MARGIN_DEPTH_CEILING`] to `[0, 100]`. T-1188: this is
|
||||
/// the continuous tone source lake shorelines were missing —
|
||||
/// shade); otherwise the settled depth `(filled - original)` at this
|
||||
/// position, normalized against THIS BASIN's own maximum depth
|
||||
/// (`HydrologySample.basin_max_depth`, bilinear-sampled at the SAME
|
||||
/// position — see that field's doc), then quantized to `[0, 100]`.
|
||||
///
|
||||
/// **Per-basin, not a fixed absolute ceiling (PR #206 eyeball finding,
|
||||
/// T-1188 round 2):** the original design used one fixed absolute-depth
|
||||
/// ceiling calibrated against a single body's p90 depth. Two compounding
|
||||
/// effects made that read visually flat on real lakes: (1) a linear
|
||||
/// absolute scale compresses the bulk of any MORE-skewed basin's depth
|
||||
/// distribution into single-digit values; (2) heightmap resolution
|
||||
/// (~40–78 km/px) means within-basin absolute-depth variation is often
|
||||
/// sub-texel-tiny (GJ1c's test basin measured a full-basin depth spread
|
||||
/// under 0.0003 normalized units — genuinely below what a fixed ceiling
|
||||
/// calibrated for a DIFFERENT body's deeper lakes could ever resolve).
|
||||
/// Normalizing against each basin's own max depth fixes both: every
|
||||
/// non-degenerate basin uses the full 0–100 range on ITS OWN terms,
|
||||
/// independent of the body's absolute elevation scale or any other
|
||||
/// basin's depth. A basin at or below
|
||||
/// [`LAKE_MARGIN_DEGENERATE_BASIN_EPSILON`] max depth reads `0`
|
||||
/// everywhere (a genuinely uniform shallow pond shades flat — honest,
|
||||
/// not forced) rather than dividing by ~zero.
|
||||
///
|
||||
/// This is the continuous tone source lake shorelines were missing —
|
||||
/// `ocean_fraction_q` is definitionally `0` throughout a lake basin (lakes
|
||||
/// sit above sea level; `ta.ocean_mask` never fires there), so every
|
||||
/// coastal-transition morphology gate (TidalFlat, DuneStrand, CliffCoast,
|
||||
@@ -2147,13 +2169,12 @@ const LAKE_MARGIN_DEPTH_CEILING: f32 = 0.06;
|
||||
/// check). `lake_margin_q` fixes the PRESENTATION gap (hypothesis (b))
|
||||
/// without touching that already-correct positional refinement.
|
||||
///
|
||||
/// Both `elevation` and `filled` are sampled via the SAME `bilinear` helper
|
||||
/// `ocean_fraction_q`'s own `ta.elev_pct`/`ta.ocean_mask` reads already use at
|
||||
/// every derive-core call site (T-1178/T-1154's per-cell rate numbers already
|
||||
/// include equivalent-cost sampling in the measured per-rung budget — no new
|
||||
/// cost category, per the workshop's own pipeline-slot ruling); computing
|
||||
/// both `is_lake` and `lake_margin_q` from the one bilinear pair costs
|
||||
/// nothing beyond the pre-existing sample.
|
||||
/// All three fields (`elevation`, `filled`, `basin_max_depth`) are sampled
|
||||
/// via the SAME `bilinear` helper `ocean_fraction_q`'s own
|
||||
/// `ta.elev_pct`/`ta.ocean_mask` reads already use at every derive-core call
|
||||
/// site (T-1178/T-1154's per-cell rate numbers already include equivalent-
|
||||
/// cost sampling in the measured per-rung budget — one more bilinear sample
|
||||
/// is not a new cost category, per the workshop's own pipeline-slot ruling).
|
||||
fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32) {
|
||||
let Some(h) = ta.hydrology.as_ref() else {
|
||||
return (false, 0);
|
||||
@@ -2162,9 +2183,13 @@ fn lake_from_hydrology_at(ta: &TerrainAnalysis, px: f64, py: f64) -> (bool, i32)
|
||||
let original = bilinear(&h.elevation, ta.w, ta.h, px, py);
|
||||
let is_lake = filled > original;
|
||||
let lake_margin_q = if is_lake {
|
||||
(((filled - original) / LAKE_MARGIN_DEPTH_CEILING) * 100.0)
|
||||
.round()
|
||||
.clamp(0.0, 100.0) as i32
|
||||
let basin_max_depth = bilinear(&h.basin_max_depth, ta.w, ta.h, px, py);
|
||||
if basin_max_depth <= LAKE_MARGIN_DEGENERATE_BASIN_EPSILON {
|
||||
0
|
||||
} else {
|
||||
let depth = (filled - original).max(0.0);
|
||||
((depth / basin_max_depth) * 100.0).round().clamp(0.0, 100.0) as i32
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
@@ -134,6 +134,14 @@ pub struct HydrologySample {
|
||||
/// water-surface height at every working-grid cell (equal to
|
||||
/// `elevation` wherever no lake exists).
|
||||
pub filled: Vec<f32>,
|
||||
/// `HydrologyResult.basin_max_depth_scaled`, rescaled back to `[0.0,
|
||||
/// 1.0]` fraction units (T-1188): the MAXIMUM settled depth anywhere in
|
||||
/// this cell's basin, broadcast to every cell in that basin, `0.0` for
|
||||
/// non-lake cells. Used to normalize `lake_margin_q` per-basin instead
|
||||
/// of against a single fixed absolute ceiling — see
|
||||
/// `district_profile::lake_from_hydrology_at`'s doc for the full
|
||||
/// rationale (the PR #206 eyeball finding that motivated this field).
|
||||
pub basin_max_depth: Vec<f32>,
|
||||
}
|
||||
|
||||
const WATER_DIST_CAP: u16 = 255;
|
||||
@@ -215,9 +223,15 @@ impl TerrainAnalysis {
|
||||
.iter()
|
||||
.map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s))
|
||||
.collect();
|
||||
let basin_max_depth: Vec<f32> = result
|
||||
.basin_max_depth_scaled
|
||||
.iter()
|
||||
.map(|&s| crate::atlas::hydrology_equilibrium::scaled_to_fraction(s))
|
||||
.collect();
|
||||
self.hydrology = Some(HydrologySample {
|
||||
elevation: elevation.to_vec(),
|
||||
filled,
|
||||
basin_max_depth,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
@@ -176,6 +176,29 @@ pub struct HydrologyResult {
|
||||
/// edge — the rim/floor discontinuity the workshop's red flag 4 asks
|
||||
/// about). Row-major, `w × h`.
|
||||
pub cliff_edge: Vec<bool>,
|
||||
/// T-1188 lake-margin normalization: for every lake cell, the MAXIMUM
|
||||
/// settled depth (`filled_scaled[c] - original_scaled[c]`, i64-scaled)
|
||||
/// anywhere in that cell's basin — broadcast to every cell in the basin
|
||||
/// (not just the deepest one), `0` for non-lake cells. Row-major, `w ×
|
||||
/// h`, computed once here (basin membership — `Basin.cells` — and both
|
||||
/// elevation fields are already in scope at the end of `solve()`) rather
|
||||
/// than re-derived by every caller.
|
||||
///
|
||||
/// **Why per-basin, not a single body-wide constant:** the PR #206
|
||||
/// eyeball found `district_profile`'s original absolute-depth
|
||||
/// `LAKE_MARGIN_DEPTH_CEILING` (a single fixed fraction calibrated
|
||||
/// against one body's p90) compressed the bulk of any more-skewed
|
||||
/// depth distribution into single-digit `lake_margin_q` values — and,
|
||||
/// separately, that heightmap resolution (~40–78 km/px) means
|
||||
/// within-basin absolute-depth variation is sometimes sub-texel-tiny
|
||||
/// (a real basin can be "shallow" in absolute normalized-elevation
|
||||
/// terms while still having a perfectly good internal shore→deep
|
||||
/// gradient relative to ITS OWN range). Normalizing against each
|
||||
/// basin's own max depth makes every non-degenerate lake use the full
|
||||
/// 0–100 tone range regardless of the body's absolute elevation scale —
|
||||
/// the perceptual point of the ticket (a lake's edge should read
|
||||
/// lighter than its centre, on every lake, not just deep ones).
|
||||
pub basin_max_depth_scaled: Vec<i64>,
|
||||
}
|
||||
|
||||
/// Convert an `i64`-scaled elevation value (as carried on
|
||||
@@ -389,11 +412,28 @@ pub fn solve(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// T-1188: per-basin max depth, broadcast to every cell in that basin.
|
||||
// `basin_cells[b]` is exactly the membership list `Basin.cells` above is
|
||||
// built from — walked directly here rather than through `basins` to
|
||||
// avoid a second clone of every basin's cell list.
|
||||
let mut basin_max_depth_scaled = vec![0i64; n];
|
||||
for b in 0..basin_count {
|
||||
let max_depth = basin_cells[b]
|
||||
.iter()
|
||||
.map(|&c| (filled[c] - original[c]).max(0))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
for &c in &basin_cells[b] {
|
||||
basin_max_depth_scaled[c] = max_depth;
|
||||
}
|
||||
}
|
||||
|
||||
HydrologyResult {
|
||||
basins,
|
||||
filled_scaled: filled,
|
||||
channel_depth_scaled,
|
||||
cliff_edge,
|
||||
basin_max_depth_scaled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user