fix(assets): mountain rock could never fire — kelvin compared to 0.35 (T-1295)

compute_biome receives absolute kelvin (the Whittaker table is kelvin; the
0-1 normalisation happens afterwards in simulate(), for the renderer only),
but two terrain rules compared it to 0.35. That was the case from the
generator's first commit (f84275edf):

- mountain rock, `temperature < 0.35`: never true, so the class never
  appeared through this rule on any body;
- sulfuric scrub, `temperature > 0.35`: always true.

0.35 can only mean a fraction of the world's own range, the normalisation
compute_moisture already uses, so both rules now compare against that. A
world with no temperature range reads 0 (the cold end).

tooling/test_planet_biome_rules.py pins the rule both ways on a synthetic
body: cold high ground turns to rock (>90%, since anomaly scatter repaints
~3%), and warm high ground and low ground do not. Mutation-proven: restoring
the kelvin comparison fails it at a rock share of exactly 0.000. Wired into
make test-tooling.

Sulfuric scrub remains unreachable for a different reason: it converts rock
only at elev_norm 0.25-0.65, and rock exists only above 0.65. It is left as
found, since making it reachable is a design call and belongs to the balancing
pass, so it is not pinned.

Found while shaping the D-258 biome bake. The committed reliefmaps and globes
were rendered with the bug, and the coming bake reflects the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 14:03:37 +02:00
co-authored by Claude Opus 5.5
parent 112c099f51
commit 3cfec9025d
5 changed files with 381 additions and 2 deletions
+157
View File
@@ -1155,3 +1155,160 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
Keep what T-1213 made it: it runs in plain cargo test, asserts the judged count and "anything changed at all" so it cannot pass vacuously, and is mutation-proven both ways (over-invention and no invention).
Blocked by T-1296.', NULL, '2026-09-25 10:55:39', '2026-09-25 10:55:39.363', '2026-09-25 10:55:39.363', NULL, '16b9e2ddf2334ddbe8051747ece340dd', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GDGD2G9K7C8SPH57RCNB8V8C', 'description', 'D-258 amendment 2026-09-25, rulings 1-4. Bake, per body, the Python planet simulator''s biome class grid TOGETHER WITH the absolute temperature (kelvin, before the display normalisation in simulate()) and moisture [0,1] it was classified from. One model supplies all three, so they agree by construction.
PRE-SEED: simulate() is keyed on the body frontmatter''s seed only. The world seed must never enter this bake. Worlds stay recognisable across playthroughs; the ground varies server-side.
MEASURE FIRST, then choose the format. The server works at 512x256 (GRID_W/GRID_H downsample), so a 1024x512 bake may be twice the resolution the server can use on each axis. Temperature and moisture are smooth fields that compress far worse than a class grid. Across ~2,600 bodies, the repository cost of candidate formats must be measured: 8-bit class PNG, 16-bit temperature, 8-bit moisture, at 1024x512 and at 512x256. Report the numbers before committing a bake.
DO NOT re-render reliefmap.png. Its render drifts by a few hundred bytes (T-1291). The bake gets its own mode, a verb on reach atlas planet, that writes only the new file(s). The heightmap is byte-identical on re-simulation, so the simulator is deterministic.
Class ids become append-only (the gas-palette selection_order discipline in biomes.toml). Gas giants get nothing. Whatever the chosen files are, register them where the atlas and canvas registries expect: the canvas-version gate (tooling/canvas_sources.py) must see a change to them.', 'D-258 amendment 2026-09-25, rulings 1-4. Bake, per body, the Python planet simulator''s biome class grid TOGETHER WITH the absolute temperature (kelvin, before the display normalisation in simulate()) and moisture [0,1] it was classified from. One model supplies all three, so they agree by construction.
PRE-SEED: simulate() is keyed on the body frontmatter''s seed only. The world seed must never enter this bake. Worlds stay recognisable across playthroughs; the ground varies server-side.
MEASURE FIRST, then choose the format. The server works at 512x256 (GRID_W/GRID_H downsample), so a 1024x512 bake may be twice the resolution the server can use on each axis. Temperature and moisture are smooth fields that compress far worse than a class grid. Across ~2,600 bodies, the repository cost of candidate formats must be measured: 8-bit class PNG, 16-bit temperature, 8-bit moisture, at 1024x512 and at 512x256. Report the numbers before committing a bake.
DO NOT re-render reliefmap.png. Its render drifts by a few hundred bytes (T-1291). The bake gets its own mode, a verb on reach atlas planet, that writes only the new file(s). The heightmap is byte-identical on re-simulation, so the simulator is deterministic.
Class ids become append-only (the gas-palette selection_order discipline in biomes.toml). Gas giants get nothing. Whatever the chosen files are, register them where the atlas and canvas registries expect: the canvas-version gate (tooling/canvas_sources.py) must see a change to them.
MEASURED 2026-09-25. Probe: simulate() on 24 bodies spaced evenly across the in-scope set, each field encoded as optimised PNG. The probe was deleted after the run.
SCOPE (Jeroen, 2026-09-25): airless bodies are out of scope for now. The format and class vocabulary must still have room for them, since classes 19-36 already cover lava, dust, ferric and lunar ground. The heightmap bake set is 267 bodies. By atmosphere: standard 231, thin 23, thick 1, reducing 1, trace 1, none 8, NULL 2. That leaves 257 bodies with an atmosphere in scope.
PER BODY (mean / max) AND TOTAL OVER 257 BODIES:
biome u8, 1024x512 11.7 / 16.2 KiB 2.9 MiB
biome u8, 512 (plurality) 5.4 / 7.5 KiB 1.4 MiB
temp u16 0.01 K, 1024 80.8 / 352 KiB 20.3 MiB
temp u16 0.01 K, 512 29.0 / 124 KiB 7.3 MiB
temp u8 1 K, 1024 14.8 / 54 KiB 3.7 MiB
temp u8 1 K, 512 6.1 / 22 KiB 1.5 MiB
moist u8, 1024 57.2 / 80 KiB 14.4 MiB
moist u8, 512 22.8 / 32 KiB 5.7 MiB
packed RGB (biome,t8,m8) 1024 96.7 / 172 KiB 24.3 MiB (worse than the three separate files at the same precision: 83.7 KiB)
REFERENCE: the committed heightmap.png averages 678 KiB per in-scope body, and reliefmap.png 295 KiB. Together that is about 245 MiB already in the tree.
Bake time: simulate() takes about 6.3 s per body, so the full bake is about 27 min.
FINDING: the simulator''s temperature is often clamped flat. temperature_bands in biomes.toml clamps each planet class ("fiction wins over physics"). On most temperate bodies that pins the field to exactly the band, 275-305 K. On some bodies it pins it to one value: GJ204c and GJ68e are 275 K everywhere, and GJ601Ad (275-281) and GJ722c (275-280) are nearly flat. On those bodies the biome variety comes from moisture alone.', NULL, '2026-09-25 11:34:42', '2026-09-25 11:34:42.605', '2026-09-25 11:34:42.605', NULL, '7534f4359c939fdb4d61072ac6e0e18d', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GDGD2G9K7C8SPH57RCNB8V8C', 'status', 'backlog', 'in_progress', NULL, '2026-09-25 11:56:08', '2026-09-25 11:56:08.363', '2026-09-25 11:56:08.363', NULL, '8e81fc3bbac841674cc25637f4b59452', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GDGD2G9K7C8SPH57RCNB8V8C', 'description', 'D-258 amendment 2026-09-25, rulings 1-4. Bake, per body, the Python planet simulator''s biome class grid TOGETHER WITH the absolute temperature (kelvin, before the display normalisation in simulate()) and moisture [0,1] it was classified from. One model supplies all three, so they agree by construction.
PRE-SEED: simulate() is keyed on the body frontmatter''s seed only. The world seed must never enter this bake. Worlds stay recognisable across playthroughs; the ground varies server-side.
MEASURE FIRST, then choose the format. The server works at 512x256 (GRID_W/GRID_H downsample), so a 1024x512 bake may be twice the resolution the server can use on each axis. Temperature and moisture are smooth fields that compress far worse than a class grid. Across ~2,600 bodies, the repository cost of candidate formats must be measured: 8-bit class PNG, 16-bit temperature, 8-bit moisture, at 1024x512 and at 512x256. Report the numbers before committing a bake.
DO NOT re-render reliefmap.png. Its render drifts by a few hundred bytes (T-1291). The bake gets its own mode, a verb on reach atlas planet, that writes only the new file(s). The heightmap is byte-identical on re-simulation, so the simulator is deterministic.
Class ids become append-only (the gas-palette selection_order discipline in biomes.toml). Gas giants get nothing. Whatever the chosen files are, register them where the atlas and canvas registries expect: the canvas-version gate (tooling/canvas_sources.py) must see a change to them.
MEASURED 2026-09-25. Probe: simulate() on 24 bodies spaced evenly across the in-scope set, each field encoded as optimised PNG. The probe was deleted after the run.
SCOPE (Jeroen, 2026-09-25): airless bodies are out of scope for now. The format and class vocabulary must still have room for them, since classes 19-36 already cover lava, dust, ferric and lunar ground. The heightmap bake set is 267 bodies. By atmosphere: standard 231, thin 23, thick 1, reducing 1, trace 1, none 8, NULL 2. That leaves 257 bodies with an atmosphere in scope.
PER BODY (mean / max) AND TOTAL OVER 257 BODIES:
biome u8, 1024x512 11.7 / 16.2 KiB 2.9 MiB
biome u8, 512 (plurality) 5.4 / 7.5 KiB 1.4 MiB
temp u16 0.01 K, 1024 80.8 / 352 KiB 20.3 MiB
temp u16 0.01 K, 512 29.0 / 124 KiB 7.3 MiB
temp u8 1 K, 1024 14.8 / 54 KiB 3.7 MiB
temp u8 1 K, 512 6.1 / 22 KiB 1.5 MiB
moist u8, 1024 57.2 / 80 KiB 14.4 MiB
moist u8, 512 22.8 / 32 KiB 5.7 MiB
packed RGB (biome,t8,m8) 1024 96.7 / 172 KiB 24.3 MiB (worse than the three separate files at the same precision: 83.7 KiB)
REFERENCE: the committed heightmap.png averages 678 KiB per in-scope body, and reliefmap.png 295 KiB. Together that is about 245 MiB already in the tree.
Bake time: simulate() takes about 6.3 s per body, so the full bake is about 27 min.
FINDING: the simulator''s temperature is often clamped flat. temperature_bands in biomes.toml clamps each planet class ("fiction wins over physics"). On most temperate bodies that pins the field to exactly the band, 275-305 K. On some bodies it pins it to one value: GJ204c and GJ68e are 275 K everywhere, and GJ601Ad (275-281) and GJ722c (275-280) are nearly flat. On those bodies the biome variety comes from moisture alone.', 'D-258 amendment 2026-09-25, rulings 1-4. Bake, per body, the Python planet simulator''s biome class grid TOGETHER WITH the absolute temperature (kelvin, before the display normalisation in simulate()) and moisture [0,1] it was classified from. One model supplies all three, so they agree by construction.
PRE-SEED: simulate() is keyed on the body frontmatter''s seed only. The world seed must never enter this bake. Worlds stay recognisable across playthroughs; the ground varies server-side.
MEASURE FIRST, then choose the format. The server works at 512x256 (GRID_W/GRID_H downsample), so a 1024x512 bake may be twice the resolution the server can use on each axis. Temperature and moisture are smooth fields that compress far worse than a class grid. Across ~2,600 bodies, the repository cost of candidate formats must be measured: 8-bit class PNG, 16-bit temperature, 8-bit moisture, at 1024x512 and at 512x256. Report the numbers before committing a bake.
DO NOT re-render reliefmap.png. Its render drifts by a few hundred bytes (T-1291). The bake gets its own mode, a verb on reach atlas planet, that writes only the new file(s). The heightmap is byte-identical on re-simulation, so the simulator is deterministic.
Class ids become append-only (the gas-palette selection_order discipline in biomes.toml). Gas giants get nothing. Whatever the chosen files are, register them where the atlas and canvas registries expect: the canvas-version gate (tooling/canvas_sources.py) must see a change to them.
MEASURED 2026-09-25. Probe: simulate() on 24 bodies spaced evenly across the in-scope set, each field encoded as optimised PNG. The probe was deleted after the run.
SCOPE (Jeroen, 2026-09-25): airless bodies are out of scope for now. The format and class vocabulary must still have room for them, since classes 19-36 already cover lava, dust, ferric and lunar ground. The heightmap bake set is 267 bodies. By atmosphere: standard 231, thin 23, thick 1, reducing 1, trace 1, none 8, NULL 2. That leaves 257 bodies with an atmosphere in scope.
PER BODY (mean / max) AND TOTAL OVER 257 BODIES:
biome u8, 1024x512 11.7 / 16.2 KiB 2.9 MiB
biome u8, 512 (plurality) 5.4 / 7.5 KiB 1.4 MiB
temp u16 0.01 K, 1024 80.8 / 352 KiB 20.3 MiB
temp u16 0.01 K, 512 29.0 / 124 KiB 7.3 MiB
temp u8 1 K, 1024 14.8 / 54 KiB 3.7 MiB
temp u8 1 K, 512 6.1 / 22 KiB 1.5 MiB
moist u8, 1024 57.2 / 80 KiB 14.4 MiB
moist u8, 512 22.8 / 32 KiB 5.7 MiB
packed RGB (biome,t8,m8) 1024 96.7 / 172 KiB 24.3 MiB (worse than the three separate files at the same precision: 83.7 KiB)
REFERENCE: the committed heightmap.png averages 678 KiB per in-scope body, and reliefmap.png 295 KiB. Together that is about 245 MiB already in the tree.
Bake time: simulate() takes about 6.3 s per body, so the full bake is about 27 min.
FINDING: the simulator''s temperature is often clamped flat. temperature_bands in biomes.toml clamps each planet class ("fiction wins over physics"). On most temperate bodies that pins the field to exactly the band, 275-305 K. On some bodies it pins it to one value: GJ204c and GJ68e are 275 K everywhere, and GJ601Ad (275-281) and GJ722c (275-280) are nearly flat. On those bodies the biome variety comes from moisture alone.
RE-SHAPED 2026-09-25 (D-258 amendment "ids, not fields"). This supersedes the three-field bake above.
Bake two u8 id maps per in-scope body (257 bodies with an atmosphere) at 1024x512:
- biomemap.png: the Whittaker class, taken BEFORE compute_biome''s terrain overrides. It covers every land pixel and is empty on thin-atmosphere bodies.
- terrainmap.png: the terrain override class, 0 where there is none.
Per-pixel temperature and moisture are NOT baked.
Also in scope here: the class tables (biome: climate envelope, flora, cover; terrain: substrate, landform hints), authored as TOML and imported into systems.db by reach ledger import under the generator stamp.
Done so far: the mountain-rock units fix in compute_biome, with tooling/test_planet_biome_rules.py (in make test-tooling). The rule compared kelvin to 0.35, so it could never fire; it now uses the world''s own normalised temperature. The test is mutation-proven. Sulfuric scrub is unreachable (it converts rock at elev_norm 0.25-0.65, but rock exists only above 0.65); it is left as found, since fixing it is a design call.
Open: story features (the anomaly scatter, ~3% of land). Their placement and rate are for a balancing pass.', NULL, '2026-09-25 12:03:24', '2026-09-25 12:03:24.975', '2026-09-25 12:03:24.975', NULL, 'c7aa6d566164afa756b1284083e89397', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GDGD2Q9SK71WJVVFJA4PTBRM', 'description', 'D-258 amendment 2026-09-25, ruling 2: the baked biome input drives the climate model, not the other way round. The server''s climate model stops originating climate at gridunit scale on baked bodies. Today that origination is latitude, lapse, greenhouse and hydrosphere in derive_temperature_c and derive_moisture_q. It scatters SUB-GRIDUNIT around the baked temperature and moisture instead: lapse from invented relief, the D-243 edge-fuzz, and local noise.
COHERENCE WITH THE PICK (T-1296): correlate the scatter with the picked class, so a picked wet class does not sit on scattered-dry ground. One way is to order the local classes along the moisture axis and drive the pick and the moisture scatter from the same noise value. The steep-drains-dry correlation composition.rs already uses is the precedent.
UNITS: the Python bake is kelvin and moisture [0,1]; the server is deci-degrees C and moisture_q 0-100. Convert once at load.
D-239''s climate-to-class laws (the treeline, the riparian band) are NOT repealed; they operate inside the scatter. Airless, gas and unbaked bodies keep the current derive.
Blocked by T-1215. Expect every golden on baked bodies to move. Each move must be explained before regenerating, as in T-1213.', 'D-258 amendment 2026-09-25, ruling 2: the baked biome input drives the climate model, not the other way round. The server''s climate model stops originating climate at gridunit scale on baked bodies. Today that origination is latitude, lapse, greenhouse and hydrosphere in derive_temperature_c and derive_moisture_q. It scatters SUB-GRIDUNIT around the baked temperature and moisture instead: lapse from invented relief, the D-243 edge-fuzz, and local noise.
COHERENCE WITH THE PICK (T-1296): correlate the scatter with the picked class, so a picked wet class does not sit on scattered-dry ground. One way is to order the local classes along the moisture axis and drive the pick and the moisture scatter from the same noise value. The steep-drains-dry correlation composition.rs already uses is the precedent.
UNITS: the Python bake is kelvin and moisture [0,1]; the server is deci-degrees C and moisture_q 0-100. Convert once at load.
D-239''s climate-to-class laws (the treeline, the riparian band) are NOT repealed; they operate inside the scatter. Airless, gas and unbaked bodies keep the current derive.
Blocked by T-1215. Expect every golden on baked bodies to move. Each move must be explained before regenerating, as in T-1213.
RE-SHAPED 2026-09-25 (D-258 amendment "ids, not fields"). No per-pixel temperature or moisture is baked. A cell''s climate is its biome classes'' envelope values from the biome table (systems.db), weighted by the bilinear class mix, and the climate model scatters sub-gridunit within that. Terrain classes carry no climate: under rock or ice, climate comes from the biome map beneath (baked pre-override), cooled by lapse from elevation.', NULL, '2026-09-25 12:03:25', '2026-09-25 12:03:25.537', '2026-09-25 12:03:25.537', NULL, 'ab936e44e24969f615e71535b90a781f', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0V0WBYS20XNMEB67A6AT8G', 'description', 'step_canvas.rs (and any other rung-derivation entry point) must be re-pointed so Region/District/Quarter/Block/Chunk all derive from the cached rung-0.5 layer rather than resolving heightmap.png/reliefmap.png directly. heightmap.rs (the rung-0 loader) gains the reliefmap as a second input if it does not already load it. This is the ''no deeper rung reads the source files'' invariant from D-258 -- cross-rung visual agreement becomes structural once this lands, rather than enforced by convention. Depends on T-1213 (generator) and T-1214 (hydrology) existing to re-point to. See governance/decisions/architecture.md#d-258 Implementation note (explicitly names step_canvas.rs and heightmap.rs as the consumers to re-point).
PREMISE GONE 2026-09-24 (D-258 amendment of that date). The stored rung-0.5 layer this ticket targets will not be built: storage was measured and is not forced (T-1212, done). Hydrology already solves once per body (layer1.rs, 114 ms on Ferrath) and every rung samples it. Left open rather than cancelled; closing it is Jeroen''s call.
RE-SCOPED 2026-09-25 (D-258 amendment of that date). The stored rung-0.5 layer will not be built. What survives of this ticket is its own text: "heightmap.rs (the rung-0 loader) gains the reliefmap as a second input". That input is now the baked biome input from T-1295, not reliefmap.png''s pixels.
Scope:
- Load the baked class/temperature/moisture files next to heightmap.png, on the same path the step-canvas and window derives use (gen_queue DeriveStepCanvas, AnalyzeBody).
- At the 512x256 downsample, keep PER-CLASS FRACTIONS per working cell (the plurality, not the winner), plus the baked climate. Store them in TerrainAnalysis, computed once per body behind the same TerrainAnalysisCache as the hydrology solve.
- Expose bilinear sampling of fractions and climate at a position, through the same coastline-warped position elevation already uses. Biome edges then blend across cell lines (invariant 1).
- Bodies without a bake (airless, gas, not yet baked) fall back to today''s derive unchanged. Byte-identical for them: prove it with the goldens.
Blocked by T-1295. Unblocks T-1296 and T-1297.', 'step_canvas.rs (and any other rung-derivation entry point) must be re-pointed so Region/District/Quarter/Block/Chunk all derive from the cached rung-0.5 layer rather than resolving heightmap.png/reliefmap.png directly. heightmap.rs (the rung-0 loader) gains the reliefmap as a second input if it does not already load it. This is the ''no deeper rung reads the source files'' invariant from D-258 -- cross-rung visual agreement becomes structural once this lands, rather than enforced by convention. Depends on T-1213 (generator) and T-1214 (hydrology) existing to re-point to. See governance/decisions/architecture.md#d-258 Implementation note (explicitly names step_canvas.rs and heightmap.rs as the consumers to re-point).
PREMISE GONE 2026-09-24 (D-258 amendment of that date). The stored rung-0.5 layer this ticket targets will not be built: storage was measured and is not forced (T-1212, done). Hydrology already solves once per body (layer1.rs, 114 ms on Ferrath) and every rung samples it. Left open rather than cancelled; closing it is Jeroen''s call.
RE-SCOPED 2026-09-25 (D-258 amendment of that date). The stored rung-0.5 layer will not be built. What survives of this ticket is its own text: "heightmap.rs (the rung-0 loader) gains the reliefmap as a second input". That input is now the baked biome input from T-1295, not reliefmap.png''s pixels.
Scope:
- Load the baked class/temperature/moisture files next to heightmap.png, on the same path the step-canvas and window derives use (gen_queue DeriveStepCanvas, AnalyzeBody).
- At the 512x256 downsample, keep PER-CLASS FRACTIONS per working cell (the plurality, not the winner), plus the baked climate. Store them in TerrainAnalysis, computed once per body behind the same TerrainAnalysisCache as the hydrology solve.
- Expose bilinear sampling of fractions and climate at a position, through the same coastline-warped position elevation already uses. Biome edges then blend across cell lines (invariant 1).
- Bodies without a bake (airless, gas, not yet baked) fall back to today''s derive unchanged. Byte-identical for them: prove it with the goldens.
Blocked by T-1295. Unblocks T-1296 and T-1297.
RE-SHAPED 2026-09-25 (D-258 amendment "ids, not fields"). Load TWO id maps, biomemap.png and terrainmap.png, and keep per-class fractions for each at the 512x256 downsample. There are no baked temperature or moisture fields. Class properties come from the biome and terrain tables in systems.db.', NULL, '2026-09-25 12:03:26', '2026-09-25 12:03:26.043', '2026-09-25 12:03:26.043', NULL, '239084659c268d3f9ca24dd6b859f517', 2) ON CONFLICT(hash) DO NOTHING;
+129
View File
@@ -1347,3 +1347,132 @@ INSERT INTO tickets (record_id, type, parent_record_id, title, description, stat
Keep what T-1213 made it: it runs in plain cargo test, asserts the judged count and "anything changed at all" so it cannot pass vacuously, and is mutation-proven both ways (over-invention and no invention).
Blocked by T-1296.', 'backlog', 'high', NULL, 'server', 'D-258', '2026-09-25 10:55:04.764', '2026-09-25 10:55:39.363', NULL, '8948621ed882acc7de9de89813c2ff9e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GDGD2G9K7C8SPH57RCNB8V8C', 'task', '06FT0TX2W0BA10PRR7NMJ2362M', 'Bake the biome input pre-seed: class id + absolute temperature + moisture per body (D-258 2026-09-25)', 'D-258 amendment 2026-09-25, rulings 1-4. Bake, per body, the Python planet simulator''s biome class grid TOGETHER WITH the absolute temperature (kelvin, before the display normalisation in simulate()) and moisture [0,1] it was classified from. One model supplies all three, so they agree by construction.
PRE-SEED: simulate() is keyed on the body frontmatter''s seed only. The world seed must never enter this bake. Worlds stay recognisable across playthroughs; the ground varies server-side.
MEASURE FIRST, then choose the format. The server works at 512x256 (GRID_W/GRID_H downsample), so a 1024x512 bake may be twice the resolution the server can use on each axis. Temperature and moisture are smooth fields that compress far worse than a class grid. Across ~2,600 bodies, the repository cost of candidate formats must be measured: 8-bit class PNG, 16-bit temperature, 8-bit moisture, at 1024x512 and at 512x256. Report the numbers before committing a bake.
DO NOT re-render reliefmap.png. Its render drifts by a few hundred bytes (T-1291). The bake gets its own mode, a verb on reach atlas planet, that writes only the new file(s). The heightmap is byte-identical on re-simulation, so the simulator is deterministic.
Class ids become append-only (the gas-palette selection_order discipline in biomes.toml). Gas giants get nothing. Whatever the chosen files are, register them where the atlas and canvas registries expect: the canvas-version gate (tooling/canvas_sources.py) must see a change to them.
MEASURED 2026-09-25. Probe: simulate() on 24 bodies spaced evenly across the in-scope set, each field encoded as optimised PNG. The probe was deleted after the run.
SCOPE (Jeroen, 2026-09-25): airless bodies are out of scope for now. The format and class vocabulary must still have room for them, since classes 19-36 already cover lava, dust, ferric and lunar ground. The heightmap bake set is 267 bodies. By atmosphere: standard 231, thin 23, thick 1, reducing 1, trace 1, none 8, NULL 2. That leaves 257 bodies with an atmosphere in scope.
PER BODY (mean / max) AND TOTAL OVER 257 BODIES:
biome u8, 1024x512 11.7 / 16.2 KiB 2.9 MiB
biome u8, 512 (plurality) 5.4 / 7.5 KiB 1.4 MiB
temp u16 0.01 K, 1024 80.8 / 352 KiB 20.3 MiB
temp u16 0.01 K, 512 29.0 / 124 KiB 7.3 MiB
temp u8 1 K, 1024 14.8 / 54 KiB 3.7 MiB
temp u8 1 K, 512 6.1 / 22 KiB 1.5 MiB
moist u8, 1024 57.2 / 80 KiB 14.4 MiB
moist u8, 512 22.8 / 32 KiB 5.7 MiB
packed RGB (biome,t8,m8) 1024 96.7 / 172 KiB 24.3 MiB (worse than the three separate files at the same precision: 83.7 KiB)
REFERENCE: the committed heightmap.png averages 678 KiB per in-scope body, and reliefmap.png 295 KiB. Together that is about 245 MiB already in the tree.
Bake time: simulate() takes about 6.3 s per body, so the full bake is about 27 min.
FINDING: the simulator''s temperature is often clamped flat. temperature_bands in biomes.toml clamps each planet class ("fiction wins over physics"). On most temperate bodies that pins the field to exactly the band, 275-305 K. On some bodies it pins it to one value: GJ204c and GJ68e are 275 K everywhere, and GJ601Ad (275-281) and GJ722c (275-280) are nearly flat. On those bodies the biome variety comes from moisture alone.', 'backlog', 'high', NULL, 'tooling', 'D-258', '2026-09-25 10:55:02.220', '2026-09-25 11:34:42.605', NULL, 'ce4eb89aaed8e523cf21401b3f506710', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GDGD2G9K7C8SPH57RCNB8V8C', 'task', '06FT0TX2W0BA10PRR7NMJ2362M', 'Bake the biome input pre-seed: class id + absolute temperature + moisture per body (D-258 2026-09-25)', 'D-258 amendment 2026-09-25, rulings 1-4. Bake, per body, the Python planet simulator''s biome class grid TOGETHER WITH the absolute temperature (kelvin, before the display normalisation in simulate()) and moisture [0,1] it was classified from. One model supplies all three, so they agree by construction.
PRE-SEED: simulate() is keyed on the body frontmatter''s seed only. The world seed must never enter this bake. Worlds stay recognisable across playthroughs; the ground varies server-side.
MEASURE FIRST, then choose the format. The server works at 512x256 (GRID_W/GRID_H downsample), so a 1024x512 bake may be twice the resolution the server can use on each axis. Temperature and moisture are smooth fields that compress far worse than a class grid. Across ~2,600 bodies, the repository cost of candidate formats must be measured: 8-bit class PNG, 16-bit temperature, 8-bit moisture, at 1024x512 and at 512x256. Report the numbers before committing a bake.
DO NOT re-render reliefmap.png. Its render drifts by a few hundred bytes (T-1291). The bake gets its own mode, a verb on reach atlas planet, that writes only the new file(s). The heightmap is byte-identical on re-simulation, so the simulator is deterministic.
Class ids become append-only (the gas-palette selection_order discipline in biomes.toml). Gas giants get nothing. Whatever the chosen files are, register them where the atlas and canvas registries expect: the canvas-version gate (tooling/canvas_sources.py) must see a change to them.
MEASURED 2026-09-25. Probe: simulate() on 24 bodies spaced evenly across the in-scope set, each field encoded as optimised PNG. The probe was deleted after the run.
SCOPE (Jeroen, 2026-09-25): airless bodies are out of scope for now. The format and class vocabulary must still have room for them, since classes 19-36 already cover lava, dust, ferric and lunar ground. The heightmap bake set is 267 bodies. By atmosphere: standard 231, thin 23, thick 1, reducing 1, trace 1, none 8, NULL 2. That leaves 257 bodies with an atmosphere in scope.
PER BODY (mean / max) AND TOTAL OVER 257 BODIES:
biome u8, 1024x512 11.7 / 16.2 KiB 2.9 MiB
biome u8, 512 (plurality) 5.4 / 7.5 KiB 1.4 MiB
temp u16 0.01 K, 1024 80.8 / 352 KiB 20.3 MiB
temp u16 0.01 K, 512 29.0 / 124 KiB 7.3 MiB
temp u8 1 K, 1024 14.8 / 54 KiB 3.7 MiB
temp u8 1 K, 512 6.1 / 22 KiB 1.5 MiB
moist u8, 1024 57.2 / 80 KiB 14.4 MiB
moist u8, 512 22.8 / 32 KiB 5.7 MiB
packed RGB (biome,t8,m8) 1024 96.7 / 172 KiB 24.3 MiB (worse than the three separate files at the same precision: 83.7 KiB)
REFERENCE: the committed heightmap.png averages 678 KiB per in-scope body, and reliefmap.png 295 KiB. Together that is about 245 MiB already in the tree.
Bake time: simulate() takes about 6.3 s per body, so the full bake is about 27 min.
FINDING: the simulator''s temperature is often clamped flat. temperature_bands in biomes.toml clamps each planet class ("fiction wins over physics"). On most temperate bodies that pins the field to exactly the band, 275-305 K. On some bodies it pins it to one value: GJ204c and GJ68e are 275 K everywhere, and GJ601Ad (275-281) and GJ722c (275-280) are nearly flat. On those bodies the biome variety comes from moisture alone.', 'in_progress', 'high', NULL, 'tooling', 'D-258', '2026-09-25 10:55:02.220', '2026-09-25 11:56:08.363', NULL, '1ca1881d2b1856e9fc71811db17c23ca', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GDGD2G9K7C8SPH57RCNB8V8C', 'task', '06FT0TX2W0BA10PRR7NMJ2362M', 'Bake the biome input pre-seed: class id + absolute temperature + moisture per body (D-258 2026-09-25)', 'D-258 amendment 2026-09-25, rulings 1-4. Bake, per body, the Python planet simulator''s biome class grid TOGETHER WITH the absolute temperature (kelvin, before the display normalisation in simulate()) and moisture [0,1] it was classified from. One model supplies all three, so they agree by construction.
PRE-SEED: simulate() is keyed on the body frontmatter''s seed only. The world seed must never enter this bake. Worlds stay recognisable across playthroughs; the ground varies server-side.
MEASURE FIRST, then choose the format. The server works at 512x256 (GRID_W/GRID_H downsample), so a 1024x512 bake may be twice the resolution the server can use on each axis. Temperature and moisture are smooth fields that compress far worse than a class grid. Across ~2,600 bodies, the repository cost of candidate formats must be measured: 8-bit class PNG, 16-bit temperature, 8-bit moisture, at 1024x512 and at 512x256. Report the numbers before committing a bake.
DO NOT re-render reliefmap.png. Its render drifts by a few hundred bytes (T-1291). The bake gets its own mode, a verb on reach atlas planet, that writes only the new file(s). The heightmap is byte-identical on re-simulation, so the simulator is deterministic.
Class ids become append-only (the gas-palette selection_order discipline in biomes.toml). Gas giants get nothing. Whatever the chosen files are, register them where the atlas and canvas registries expect: the canvas-version gate (tooling/canvas_sources.py) must see a change to them.
MEASURED 2026-09-25. Probe: simulate() on 24 bodies spaced evenly across the in-scope set, each field encoded as optimised PNG. The probe was deleted after the run.
SCOPE (Jeroen, 2026-09-25): airless bodies are out of scope for now. The format and class vocabulary must still have room for them, since classes 19-36 already cover lava, dust, ferric and lunar ground. The heightmap bake set is 267 bodies. By atmosphere: standard 231, thin 23, thick 1, reducing 1, trace 1, none 8, NULL 2. That leaves 257 bodies with an atmosphere in scope.
PER BODY (mean / max) AND TOTAL OVER 257 BODIES:
biome u8, 1024x512 11.7 / 16.2 KiB 2.9 MiB
biome u8, 512 (plurality) 5.4 / 7.5 KiB 1.4 MiB
temp u16 0.01 K, 1024 80.8 / 352 KiB 20.3 MiB
temp u16 0.01 K, 512 29.0 / 124 KiB 7.3 MiB
temp u8 1 K, 1024 14.8 / 54 KiB 3.7 MiB
temp u8 1 K, 512 6.1 / 22 KiB 1.5 MiB
moist u8, 1024 57.2 / 80 KiB 14.4 MiB
moist u8, 512 22.8 / 32 KiB 5.7 MiB
packed RGB (biome,t8,m8) 1024 96.7 / 172 KiB 24.3 MiB (worse than the three separate files at the same precision: 83.7 KiB)
REFERENCE: the committed heightmap.png averages 678 KiB per in-scope body, and reliefmap.png 295 KiB. Together that is about 245 MiB already in the tree.
Bake time: simulate() takes about 6.3 s per body, so the full bake is about 27 min.
FINDING: the simulator''s temperature is often clamped flat. temperature_bands in biomes.toml clamps each planet class ("fiction wins over physics"). On most temperate bodies that pins the field to exactly the band, 275-305 K. On some bodies it pins it to one value: GJ204c and GJ68e are 275 K everywhere, and GJ601Ad (275-281) and GJ722c (275-280) are nearly flat. On those bodies the biome variety comes from moisture alone.
RE-SHAPED 2026-09-25 (D-258 amendment "ids, not fields"). This supersedes the three-field bake above.
Bake two u8 id maps per in-scope body (257 bodies with an atmosphere) at 1024x512:
- biomemap.png: the Whittaker class, taken BEFORE compute_biome''s terrain overrides. It covers every land pixel and is empty on thin-atmosphere bodies.
- terrainmap.png: the terrain override class, 0 where there is none.
Per-pixel temperature and moisture are NOT baked.
Also in scope here: the class tables (biome: climate envelope, flora, cover; terrain: substrate, landform hints), authored as TOML and imported into systems.db by reach ledger import under the generator stamp.
Done so far: the mountain-rock units fix in compute_biome, with tooling/test_planet_biome_rules.py (in make test-tooling). The rule compared kelvin to 0.35, so it could never fire; it now uses the world''s own normalised temperature. The test is mutation-proven. Sulfuric scrub is unreachable (it converts rock at elev_norm 0.25-0.65, but rock exists only above 0.65); it is left as found, since fixing it is a design call.
Open: story features (the anomaly scatter, ~3% of land). Their placement and rate are for a balancing pass.', 'in_progress', 'high', NULL, 'tooling', 'D-258', '2026-09-25 10:55:02.220', '2026-09-25 12:03:24.975', NULL, 'd9ac3d9f65bc58a01741e76e8a1bcba7', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GDGD2Q9SK71WJVVFJA4PTBRM', 'task', '06FT0TX2W0BA10PRR7NMJ2362M', 'Climate model takes the baked input: temperature and moisture scatter sub-gridunit around it', 'D-258 amendment 2026-09-25, ruling 2: the baked biome input drives the climate model, not the other way round. The server''s climate model stops originating climate at gridunit scale on baked bodies. Today that origination is latitude, lapse, greenhouse and hydrosphere in derive_temperature_c and derive_moisture_q. It scatters SUB-GRIDUNIT around the baked temperature and moisture instead: lapse from invented relief, the D-243 edge-fuzz, and local noise.
COHERENCE WITH THE PICK (T-1296): correlate the scatter with the picked class, so a picked wet class does not sit on scattered-dry ground. One way is to order the local classes along the moisture axis and drive the pick and the moisture scatter from the same noise value. The steep-drains-dry correlation composition.rs already uses is the precedent.
UNITS: the Python bake is kelvin and moisture [0,1]; the server is deci-degrees C and moisture_q 0-100. Convert once at load.
D-239''s climate-to-class laws (the treeline, the riparian band) are NOT repealed; they operate inside the scatter. Airless, gas and unbaked bodies keep the current derive.
Blocked by T-1215. Expect every golden on baked bodies to move. Each move must be explained before regenerating, as in T-1213.
RE-SHAPED 2026-09-25 (D-258 amendment "ids, not fields"). No per-pixel temperature or moisture is baked. A cell''s climate is its biome classes'' envelope values from the biome table (systems.db), weighted by the bilinear class mix, and the climate model scatters sub-gridunit within that. Terrain classes carry no climate: under rock or ice, climate comes from the biome map beneath (baked pre-override), cooled by lapse from elevation.', 'backlog', 'high', NULL, 'server', 'D-258', '2026-09-25 10:55:04.014', '2026-09-25 12:03:25.537', NULL, '821609d72a504d38b89eb3877017fead', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FT0V0WBYS20XNMEB67A6AT8G', 'task', '06FT0TX2W0BA10PRR7NMJ2362M', 'Re-point every Atlas rung to source from rung 0.5, not the raw heightmap/reliefmap', 'step_canvas.rs (and any other rung-derivation entry point) must be re-pointed so Region/District/Quarter/Block/Chunk all derive from the cached rung-0.5 layer rather than resolving heightmap.png/reliefmap.png directly. heightmap.rs (the rung-0 loader) gains the reliefmap as a second input if it does not already load it. This is the ''no deeper rung reads the source files'' invariant from D-258 -- cross-rung visual agreement becomes structural once this lands, rather than enforced by convention. Depends on T-1213 (generator) and T-1214 (hydrology) existing to re-point to. See governance/decisions/architecture.md#d-258 Implementation note (explicitly names step_canvas.rs and heightmap.rs as the consumers to re-point).
PREMISE GONE 2026-09-24 (D-258 amendment of that date). The stored rung-0.5 layer this ticket targets will not be built: storage was measured and is not forced (T-1212, done). Hydrology already solves once per body (layer1.rs, 114 ms on Ferrath) and every rung samples it. Left open rather than cancelled; closing it is Jeroen''s call.
RE-SCOPED 2026-09-25 (D-258 amendment of that date). The stored rung-0.5 layer will not be built. What survives of this ticket is its own text: "heightmap.rs (the rung-0 loader) gains the reliefmap as a second input". That input is now the baked biome input from T-1295, not reliefmap.png''s pixels.
Scope:
- Load the baked class/temperature/moisture files next to heightmap.png, on the same path the step-canvas and window derives use (gen_queue DeriveStepCanvas, AnalyzeBody).
- At the 512x256 downsample, keep PER-CLASS FRACTIONS per working cell (the plurality, not the winner), plus the baked climate. Store them in TerrainAnalysis, computed once per body behind the same TerrainAnalysisCache as the hydrology solve.
- Expose bilinear sampling of fractions and climate at a position, through the same coastline-warped position elevation already uses. Biome edges then blend across cell lines (invariant 1).
- Bodies without a bake (airless, gas, not yet baked) fall back to today''s derive unchanged. Byte-identical for them: prove it with the goldens.
Blocked by T-1295. Unblocks T-1296 and T-1297.
RE-SHAPED 2026-09-25 (D-258 amendment "ids, not fields"). Load TWO id maps, biomemap.png and terrainmap.png, and keep per-class fractions for each at the 512x256 downsample. There are no baked temperature or moisture fields. Class properties come from the biome and terrain tables in systems.db.', 'backlog', 'high', NULL, NULL, 'D-258', '2026-07-26 21:54:27.551', '2026-09-25 12:03:26.043', NULL, '7abe455db4683c4b7179c0e82a9ccb58', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
+3
View File
@@ -293,6 +293,9 @@ test-tooling:
@echo " [test-tooling] oasis ring-scaling pin (T-964, PR #210 review)..."
@$(VENV_PY) tooling/test_planet_oasis_rings.py 2> .cache/test-tooling-oasis-ring.log || \
{ echo " FAIL: oasis ring scaling — log follows:"; cat .cache/test-tooling-oasis-ring.log; exit 1; }
@echo " [test-tooling] mountain-rock rule compares on the world's own scale (T-1295)..."
@$(VENV_PY) tooling/test_planet_biome_rules.py 2> .cache/test-tooling-biome-rules.log || \
{ echo " FAIL: biome rules — log follows:"; cat .cache/test-tooling-biome-rules.log; exit 1; }
@echo " [test-tooling] reach atlas planet options vs module parsers (T-1288)..."
@$(VENV_PY) tooling/test_planet_router.py 2> .cache/test-tooling-planet-router.log || \
{ echo " FAIL: planet router drift — log follows:"; cat .cache/test-tooling-planet-router.log; exit 1; }
@@ -716,7 +716,15 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
has_ice_deposition = atmo not in ("none",) and hydro_here not in ("none", "subsurface")
if has_ice_deposition:
biome[land & (elev_norm > 0.85)] = 17
biome[land & (elev_norm > 0.65) & (temperature < 0.35)] = 18
# `temperature` is absolute kelvin here, but the two rules below were
# written against a 0-1 scale and compared kelvin to 0.35 from the first
# commit: mountain rock could never fire, and the sulfuric test was always
# true (T-1295). 0.35 only means something as a fraction of this world's own
# range — the same normalisation simulate() applies for the renderer — so
# that is what they now compare against. A world with no range reads 0.
t_span = float(temperature.max() - temperature.min())
t_norm = (temperature - temperature.min()) / t_span if t_span > 0 else np.zeros_like(temperature)
biome[land & (elev_norm > 0.65) & (t_norm < 0.35)] = 18
# ── Modifier stack ─────────────────────────────────────────────────────
env = body_def.get("environment", {})
@@ -751,7 +759,7 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
# Sulfuric substrate: scrub on volcanic mid-elevations
if substrate == "sulfuric":
scrub = land & (elev_norm > 0.25) & (elev_norm < 0.65) & (temperature > 0.35)
scrub = land & (elev_norm > 0.25) & (elev_norm < 0.65) & (t_norm > 0.35)
biome[scrub & (biome == 18)] = EXOTIC_CLASSES["sulfuric_scrub"]
# ── Anomaly scatter ─────────────────────────────────────────────────
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""
Pins the mountain-rock rule in `compute_biome`, which compared kelvin
against 0.35 (T-1295).
`compute_biome` receives absolute kelvin, but the rule was written against a
0-1 scale, so from the generator's first commit mountain rock could never
fire. Nothing noticed, because no test asked the rule to fire, or to hold
back. These do both, on a synthetic body whose answer is known.
The sulfuric-scrub rule had the same unit bug and is fixed alongside it, but
it is not pinned here: it converts rock only at elev_norm 0.25-0.65, while
rock exists only above 0.65, so it cannot fire at all. Making it reachable is
a design call, not a units fix.
Run directly or via `make test-tooling`:
.venv/bin/python tooling/test_planet_biome_rules.py
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import numpy as np # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import ( # noqa: E402
GRID_H,
GRID_W,
compute_biome,
)
MOUNTAIN_ROCK = 18
SEA_LEVEL = 0.2
def _body(substrate: str = "silicate") -> dict:
return {
"seed": 7,
"planet_class": "temperate",
"body_type": "planet",
"physical": {"atmosphere": "standard"},
# No hydrosphere: keeps high ground out of the snow rule, so rock is
# the only rule that can claim it.
"environment": {"hydrosphere": "none", "substrate": substrate},
}
def _world(temp_k: np.ndarray, elev: np.ndarray) -> np.ndarray:
water = np.zeros((GRID_H, GRID_W), dtype=bool)
moisture = np.full((GRID_H, GRID_W), 0.5, dtype=np.float32)
return compute_biome(_body(), elev, SEA_LEVEL, water, temp_k, moisture)
class MountainRockTests(unittest.TestCase):
def setUp(self):
# West half high ground, east half low. Temperature runs 275-305 K
# west to east, the temperate band, so the high west is the cold third.
self.elev = np.full((GRID_H, GRID_W), 0.3, dtype=np.float32)
self.elev[:, : GRID_W // 2] = 0.95 # elev_norm ~0.94, above the 0.65 gate
ramp = np.linspace(275.0, 305.0, GRID_W, dtype=np.float32)
self.temp = np.tile(ramp, (GRID_H, 1))
def test_cold_high_ground_is_rock(self):
biome = _world(self.temp, self.elev)
cold_high = biome[:, : int(GRID_W * 0.30)]
share = float((cold_high == MOUNTAIN_ROCK).mean())
# The anomaly scatter repaints ~3% of land, so "all of it" is too
# strong; the dead rule scored exactly 0 here.
self.assertGreater(share, 0.9, f"cold high ground rock share {share:.3f}")
def test_warm_or_low_ground_is_not_rock(self):
biome = _world(self.temp, self.elev)
warm_high = biome[:, int(GRID_W * 0.40) : GRID_W // 2]
low = biome[:, GRID_W // 2 :]
self.assertEqual(int((warm_high == MOUNTAIN_ROCK).sum()), 0)
self.assertEqual(int((low == MOUNTAIN_ROCK).sum()), 0)
if __name__ == "__main__":
unittest.main()