feat(assets): biome and terrain as two maps, and the bake that writes them (T-1295)

D-258 rulings 5 and 6: the pre-seed biome input is class ids only, on two
maps, because compute_biome was already computing them apart and throwing
one away. It gave every land pixel its Whittaker (climate) class, then painted
terrain over it (ocean bands, ice, altitude snow, mountain rock, lava and ash,
the dry, lunar and ferric ground).

planet_simulation:
- compute_biome_layers returns (biome, terrain). Each rule declares the layer
  it writes, by RULE not by class: ice from the cold climate box is biome,
  altitude snow is terrain with the lowland biome kept beneath. Rules that
  place life (thermophiles, mats, crust, the oasis rings) write the biome and
  clear the terrain under them.
- 255 means "nothing on this layer", not 0, because class 0 is ocean_deep,
  itself terrain. Ids stay literal.
- compute_biome is now compose_layers() of the pair, so the renderers are
  untouched. Proven byte-identical, dtype included, on 65 real bodies: every
  8th standard-atmosphere body plus every thin, thick, reducing and trace one,
  captured before the edit and compared after it.
- simulate() carries both maps as biome_layer / terrain_layer.

reach atlas planet bake-biome [--body --limit --dry-run --check]:
- Writes biomemap.png + terrainmap.png (8-bit, 1024x512, tEXt: layer, none,
  classes, decision) beside each heightmap. Scope is the heightmap bake set
  with an atmosphere (257 bodies; airless are deferred, per D-258).
- Pre-seed by construction: simulate() is keyed on the body frontmatter's
  seed, and no world seed is accepted anywhere.
- --check re-simulates and compares DECODED PIXELS, never bytes, since PNG
  encoding drifts across Pillow/zlib versions (T-1291) and a check that trips
  on that gets muted. Mutation-proven: one flipped pixel exits 1 and names
  the file; the restored file exits 0.
- import_heightmaps' body lookup is extracted as body_def_for and shared,
  not copied.

Tests (make test-tooling): rock keeps its biome beneath, thin-atmosphere
ground is terrain with only scattered life, water is terrain only, no pixel is
empty on both maps, and stacking equals the rendered grid. Routing rock
through the biome map fails two of them by name. The router drift test covers
the new verb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 15:55:47 +02:00
co-authored by Claude Opus 5.5
parent 10811029e5
commit 76e3f3ec96
8 changed files with 562 additions and 39 deletions
+114
View File
@@ -1312,3 +1312,117 @@ Scope:
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;
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.
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.', '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.
SCOPE MOVE 2026-09-25: the class tables (biome and terrain, TOML imported into systems.db) move to T-1297, their first consumer. Nothing in this ticket reads them, and their columns should be set by what the climate model needs, not guessed ahead of it. T-1295 is now the two id maps, the bake verb, and the simulator split.
Refactor proof: compute_biome was split into compute_biome_layers, and compose_layers(biome, terrain) is byte-identical to the old single grid on 65 real bodies. That covers every standard-atmosphere body at a stride of 8, plus every thin, thick, reducing and trace body. The baseline was captured before the edit and compared after it.', NULL, '2026-09-25 13:08:42', '2026-09-25 13:08:42.649', '2026-09-25 13:08:42.649', NULL, '1ef15787992bad10ef8809972ab8a1fc', 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.
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.', '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.
SCOPE ADDED 2026-09-25 (moved from T-1295): the class tables. Author a biome table and a terrain table as TOML. The source today is tooling/domains/atlas/planet/biomes.toml, and whether it moves into wiki/ is still open. Import them into systems.db via reach ledger import, under the generator stamp: register the TOML in tooling/generator_sources.py, add the DDL to systems-schema.sql and MIGRATION_SQL. Columns are whatever this ticket''s climate model needs: at minimum the biome climate envelope (from the Whittaker boxes, whose ids 9, 11, 16 and 17 have two boxes each). Terrain classes carry no climate.
Layer and id facts from T-1295: 255 means none on both maps; ids are literal biomes.toml ids; and layer is decided per rule, so class 17 (ice) appears in BOTH tables.', NULL, '2026-09-25 13:08:43', '2026-09-25 13:08:43.221', '2026-09-25 13:08:43.221', NULL, '58e03d1b8272be23f3fa899120aab7d9', 2) ON CONFLICT(hash) DO NOTHING;
+62
View File
@@ -1476,3 +1476,65 @@ Scope:
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;
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.
SCOPE MOVE 2026-09-25: the class tables (biome and terrain, TOML imported into systems.db) move to T-1297, their first consumer. Nothing in this ticket reads them, and their columns should be set by what the climate model needs, not guessed ahead of it. T-1295 is now the two id maps, the bake verb, and the simulator split.
Refactor proof: compute_biome was split into compute_biome_layers, and compose_layers(biome, terrain) is byte-identical to the old single grid on 65 real bodies. That covers every standard-atmosphere body at a stride of 8, plus every thin, thick, reducing and trace body. The baseline was captured before the edit and compared after it.', 'in_progress', 'high', NULL, 'tooling', 'D-258', '2026-09-25 10:55:02.220', '2026-09-25 13:08:42.644', NULL, 'e14813897f0881511a2a36b60c68fd0d', 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.
SCOPE ADDED 2026-09-25 (moved from T-1295): the class tables. Author a biome table and a terrain table as TOML. The source today is tooling/domains/atlas/planet/biomes.toml, and whether it moves into wiki/ is still open. Import them into systems.db via reach ledger import, under the generator stamp: register the TOML in tooling/generator_sources.py, add the DDL to systems-schema.sql and MIGRATION_SQL. Columns are whatever this ticket''s climate model needs: at minimum the biome climate envelope (from the Whittaker boxes, whose ids 9, 11, 16 and 17 have two boxes each). Terrain classes carry no climate.
Layer and id facts from T-1295: 255 means none on both maps; ids are literal biomes.toml ids; and layer is decided per rule, so class 17 (ice) appears in BOTH tables.', 'backlog', 'high', NULL, 'server', 'D-258', '2026-09-25 10:55:04.014', '2026-09-25 13:08:43.221', NULL, '944729a58c334e95c504f040b35a9178', 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;
+184
View File
@@ -0,0 +1,184 @@
"""bake_biome — the pre-seed biome input, baked per body (D-258, T-1295).
For every in-scope body this writes two 8-bit class-id maps beside its
`heightmap.png`, at the same 1024x512:
biomemap.png the climate and life class, taken before terrain is laid
over it, so it covers every land pixel, rock and ice included
terrainmap.png the landform or substrate class a terrain rule laid down
Both use 255 for "nothing on this layer" and literal class ids otherwise
(`biomes.toml`); what a class MEANS lives in the class tables, not here.
PRE-SEED by construction. `simulate()` is keyed on the body frontmatter's own
`seed`, and nothing here takes a world seed, so a body bakes to the same maps
in every playthrough; the server varies the ground, not the map.
SCOPE: bodies with an atmosphere, out of the heightmap bake set. Airless bodies
are deferred (D-258, 2026-09-25). Gas giants have no surface and are skipped.
`--check` re-simulates and compares decoded pixels, never file bytes: PNG
encoding can drift across Pillow/zlib versions (T-1291) without the map
changing, and a check that trips on that would be muted.
Run through reach:
reach atlas planet bake-biome --limit 3 # smoke test
reach atlas planet bake-biome # all in-scope bodies (~27 min; --detach)
reach atlas planet bake-biome --check # fail if committed maps are stale
"""
from __future__ import annotations
import argparse
import sqlite3
import time
from pathlib import Path
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from tooling.core import config, console
from tooling.core.errors import ReachError
from tooling.domains.atlas.planet.import_heightmaps import body_def_for
from tooling.domains.atlas.planet.planet_simulation import LAYER_NONE, simulate
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
BIOME_FILE = "biomemap.png"
TERRAIN_FILE = "terrainmap.png"
# The heightmap bake set, narrowed to bodies with an atmosphere.
SCOPE_SQL = (
"SELECT body_id, terrain_reference FROM bodies "
"WHERE inhabited=1 AND terrain_reference IS NOT NULL AND population>0 "
"AND system_id != 'GJ 0' "
"AND atmosphere IS NOT NULL AND atmosphere != 'none' "
"ORDER BY body_id"
)
def in_scope(db_path: Path) -> list[tuple[str, str]]:
"""`(body_id, terrain_reference)` for every body this bake covers."""
conn = sqlite3.connect(str(db_path))
try:
return conn.execute(SCOPE_SQL).fetchall()
finally:
conn.close()
def layers_for(bd: dict) -> tuple[np.ndarray, np.ndarray] | None:
"""The two maps for one body, or None for a gas giant (no surface)."""
terrain = simulate(bd)
if not terrain:
return None
return terrain["biome_layer"], terrain["terrain_layer"]
def _png(layer: np.ndarray, name: str) -> tuple[Image.Image, PngInfo]:
meta = PngInfo()
meta.add_text("layer", name)
meta.add_text("none", str(LAYER_NONE))
meta.add_text("classes", "tooling/domains/atlas/planet/biomes.toml")
meta.add_text("decision", "D-258")
return Image.fromarray(layer.astype(np.uint8), mode="L"), meta
def write_layers(body_dir: Path, biome: np.ndarray, terrain: np.ndarray) -> None:
for layer, name, fname in ((biome, "biome", BIOME_FILE), (terrain, "terrain", TERRAIN_FILE)):
img, meta = _png(layer, name)
img.save(body_dir / fname, pnginfo=meta, optimize=True)
def stale_layers(body_dir: Path, biome: np.ndarray, terrain: np.ndarray) -> list[str]:
"""Which committed maps differ, pixel for pixel, from a fresh simulation."""
stale = []
for layer, fname in ((biome, BIOME_FILE), (terrain, TERRAIN_FILE)):
path = body_dir / fname
if not path.exists():
stale.append(f"{fname} missing")
continue
on_disk = np.asarray(Image.open(path))
if on_disk.shape != layer.shape or not np.array_equal(on_disk, layer):
stale.append(fname)
return stale
def main(argv: list[str] | None = None) -> None:
ap = argparse.ArgumentParser(description="Bake biomemap.png + terrainmap.png per body (D-258)")
ap.add_argument("--db", default=str(DB_PATH))
ap.add_argument("--body", help="Bake only this body_id")
ap.add_argument("--limit", type=int, help="Bake at most N bodies (smoke test)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--check", action="store_true",
help="Write nothing; fail if any committed map differs from a fresh bake")
args = ap.parse_args(argv)
db_path = Path(args.db)
if not db_path.exists():
raise ReachError(f"{db_path} not found",
fix="make regen-db, or point --db at an existing systems.db")
rows = in_scope(db_path)
if args.body:
rows = [r for r in rows if r[0] == args.body]
if not rows:
raise ReachError(
f"{args.body} is not in the bake scope",
fix="the scope is inhabited, populated, non-Sol bodies with an atmosphere; "
"reach atlas planet bake-biome --dry-run lists it",
)
if args.limit:
rows = rows[: args.limit]
mode = "check" if args.check else "dry run" if args.dry_run else "bake"
console.event(f"biome bake ({mode}): {len(rows)} bodies")
parsed: dict[str, list] = {}
n_done = n_gas = 0
errors: list[str] = []
stale: list[str] = []
t0 = time.time()
for i, (body_id, tref) in enumerate(rows):
try:
bd = body_def_for(body_id, tref, parsed)
except Exception as exc: # noqa: BLE001 — per-body, reported and counted
errors.append(f"{body_id}: {exc}")
continue
if args.dry_run:
n_done += 1
continue
layers = layers_for(bd)
if layers is None:
n_gas += 1
continue
body_dir = REPO_ROOT / Path(tref).parent
if args.check:
diff = stale_layers(body_dir, *layers)
if diff:
stale.append(f"{body_id}: {', '.join(diff)}")
else:
write_layers(body_dir, *layers)
n_done += 1
if (i + 1) % 25 == 0:
console.event(f" [{i + 1}/{len(rows)}] {body_id}")
summary = (f"{n_done} bodies, {n_gas} gas giants skipped, {len(errors)} errors "
f"in {time.time() - t0:.0f}s")
for line in errors:
console.event(f" ERROR {line}", level="error")
if errors:
raise ReachError(
f"biome bake: {summary}",
fix="each ERROR line names the body; its system page's Celestial Bodies table "
"is what body_definition_parser reads",
)
if stale:
for line in stale:
console.event(f" STALE {line}", level="error")
raise ReachError(
f"{len(stale)} bodies' biome maps are stale",
fix="reach atlas planet bake-biome, then stage the maps under wiki/star-systems",
)
console.verdict(f"biome bake ({mode}): {summary}")
@@ -67,6 +67,23 @@ def rename_legacy_heightmaps(dry_run: bool) -> int:
return n
def body_def_for(body_id: str, tref: str, parsed: dict[str, list]) -> dict:
"""The parsed body definition for one baked body.
`tref` is the body's terrain_reference (a path under wiki/star-systems);
`parsed` caches each system's parse, since sibling bodies share it. Raises
on a parse failure or a body missing from its system, so callers report
it per body and carry on.
"""
sys_index = str(REPO_ROOT / Path(tref.split("/bodies/")[0]) / "index.md")
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
bd = next((d for d in defs if d.get("id") == body_id), None)
if bd is None:
raise LookupError("not in system defs")
return bd
def bake_body(bd: dict, body_dir: Path, dry_run: bool) -> dict:
"""Simulate one body at 1024×512 and write reliefmap.png + 16-bit heightmap.png."""
try:
@@ -134,17 +151,10 @@ def main(argv: list[str] | None = None) -> None:
n_baked = n_gas = n_err = 0
for i, (body_id, tref) in enumerate(rows):
body_dir = REPO_ROOT / Path(tref).parent
sys_index = str(REPO_ROOT / Path(tref.split("/bodies/")[0]) / "index.md")
try:
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
bd = body_def_for(body_id, tref, parsed)
except Exception as exc: # noqa: BLE001
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
n_err += 1
continue
bd = next((d for d in defs if d.get("id") == body_id), None)
if bd is None:
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {exc}")
n_err += 1
continue
ts = time.time()
@@ -11,7 +11,9 @@ Output terrain dict:
"elevation": float32 (H, W) [0, 1] normalised elevation
"temperature": float32 (H, W) [0, 1] 0=coldest, 1=hottest
"moisture": float32 (H, W) [0, 1] 0=driest, 1=wettest
"biome": int8 (H, W) biome class index
"biome": int8 (H, W) biome class index (composed)
"biome_layer": uint8 (H, W) climate/life class, 255 = none
"terrain_layer": uint8 (H, W) landform/substrate class, 255 = none
"surface_water": bool (H, W) ocean/lake mask
"hillshade": float32 (H, W) [0, 1] lighting from slope+aspect
"sea_level": float elevation threshold
@@ -617,11 +619,54 @@ def compute_hillshade(elevation,
# WHITTAKER_TABLE, EXOTIC_CLASSES loaded from biomes.toml via biome_config
# "Nothing on this layer" in the biome and terrain maps. Not 0: class 0 is
# ocean_deep, itself a terrain class, and every id stays literal (D-258).
LAYER_NONE = 255
def compose_layers(biome, terrain):
"""The single class grid the renderers draw: terrain where there is any,
the biome beneath it everywhere else."""
return np.where(terrain != LAYER_NONE, terrain, biome).astype(np.int8)
def compute_biome(body_def, elevation, sea_level, surface_water,
temperature, moisture):
H, W = elevation.shape
biome = np.zeros((H, W), dtype=np.int8)
land = ~surface_water
"""The composed class grid — what the reliefmap and globe renders colour."""
return compose_layers(*compute_biome_layers(
body_def, elevation, sea_level, surface_water, temperature, moisture))
def compute_biome_layers(body_def, elevation, sea_level, surface_water,
temperature, moisture):
"""Biome and terrain as two maps (D-258, 2026-09-25 rulings 5 and 6).
The biome map is the climate and life class; the terrain map is the
landform or substrate a terrain rule laid over it. Each rule below writes
the layer it belongs to, by RULE rather than by class: ice from the cold
climate box is biome, snow on a tropical summit is terrain with the tropical
biome kept beneath it. A biome rule that lands on terrain (a crust of life
on bare rock, a shrub patch on a geothermal spot) replaces the terrain there,
exactly as it replaced it in the single grid, so compose_layers() of the
result is byte-identical to what compute_biome returned before the split.
Both maps use LAYER_NONE for "nothing on this layer": water and
thin-atmosphere ground carry no biome, most land carries no terrain.
"""
H, W = elevation.shape
biome = np.full((H, W), LAYER_NONE, dtype=np.uint8)
terrain = np.full((H, W), LAYER_NONE, dtype=np.uint8)
land = ~surface_water
def paint_terrain(mask, cls):
terrain[mask] = cls
def paint_biome(mask, cls):
biome[mask] = cls
terrain[mask] = LAYER_NONE
def composed():
return np.where(terrain != LAYER_NONE, terrain, biome)
atmo = body_def["physical"]["atmosphere"]
@@ -669,7 +714,8 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
cf[(en > 0.70) & (tf < 273)] = 17 # high + cold = ice cap
cf[(en < 0.20) & (tf >= 260)] = 27 # low elevation = dust plain
biome[land] = cf
# Bare ground, no life: terrain, with no biome beneath it.
paint_terrain(land, cf)
else:
# --- Standard Whittaker lookup for breathable/toxic atmospheres ---
tf = temperature[land].ravel()
@@ -681,14 +727,14 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
mask = (tf >= tlo) & (tf <= thi) & (mf >= mlo) & (mf <= mhi)
cf[mask] = cls
biome[land] = cf
biome[land] = cf # the climate class — the biome map's whole reason to exist
# Ocean depth bands
if surface_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
biome[surface_water & (depth < 0.15)] = 2
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1
biome[surface_water & (depth >= 0.50)] = 0
paint_terrain(surface_water & (depth < 0.15), 2)
paint_terrain(surface_water & (depth >= 0.15) & (depth < 0.50), 1)
paint_terrain(surface_water & (depth >= 0.50), 0)
# Frozen ocean — override ocean biome with ice shelf (class 26).
# Distinct from land ice (17) — slightly different appearance,
@@ -701,11 +747,13 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
gain=0.5, base_freq=3.0) * 2.0 - 1.0
freeze_threshold = 271.0 + ice_noise * 8.0 # ±8K variation
frozen_ocean = surface_water & (temperature < freeze_threshold)
biome[frozen_ocean] = 26
paint_terrain(frozen_ocean, 26)
# Very cold override — only on worlds with atmosphere (ice needs deposition)
# Very cold override — only on worlds with atmosphere (ice needs deposition).
# Biome, not terrain: it is the climate's own verdict, the same class the
# Whittaker ice box gives, so it carries that climate.
if atmo not in ("none",):
biome[(temperature < 243.0) & land] = 17 # below -30C → ice
paint_biome((temperature < 243.0) & land, 17) # below -30C → ice
# Elevation overrides — mountain rock and permanent snow.
# Only apply snow on worlds with atmosphere (ice needs deposition).
@@ -715,7 +763,8 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
hydro_here = body_def.get("environment", {}).get("hydrosphere", "none")
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
# Altitude snow is terrain: a glacier with the lowland biome beneath.
paint_terrain(land & (elev_norm > 0.85), 17)
# `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
@@ -724,7 +773,7 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
# 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
paint_terrain(land & (elev_norm > 0.65) & (t_norm < 0.35), 18)
# ── Modifier stack ─────────────────────────────────────────────────────
env = body_def.get("environment", {})
@@ -737,30 +786,36 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
# Geothermal: volcanic worlds get lava/ash at high elevations
if geothermal in ("extreme", "high") and planet_class == "volcanic":
biome[land & (elev_norm > 0.75)] = EXOTIC_CLASSES["lava_field"]
biome[land & (elev_norm > 0.45) & (elev_norm <= 0.75)] = EXOTIC_CLASSES["ash_field"]
paint_terrain(land & (elev_norm > 0.75), EXOTIC_CLASSES["lava_field"])
paint_terrain(land & (elev_norm > 0.45) & (elev_norm <= 0.75), EXOTIC_CLASSES["ash_field"])
# The four rules below place LIFE (thermophiles, mats, crusts, scrub), so
# they write the biome map and replace any terrain under them.
# Thermophilic fields near heat vents on any high-geothermal world
if geothermal in ("extreme", "high") and not chemosyn:
hot = (temperature > 303.0) & land & (elev_norm < 0.45)
biome[hot] = EXOTIC_CLASSES["thermophilic_field"]
paint_biome(hot, EXOTIC_CLASSES["thermophilic_field"])
# Chemosynthetic worlds (Europa-type): cold surface, geothermal warmth
if chemosyn:
geo_warm = (temperature > 263.0) & (temperature < 293.0) & land
biome[geo_warm] = EXOTIC_CLASSES["chemosynthetic_mat"]
paint_biome(geo_warm, EXOTIC_CLASSES["chemosynthetic_mat"])
# UV radiation: cryptobiotic crust on exposed terrain with thin/no atmo
if uv_index in ("extreme", "high") and atmo in ("none", "thin"):
shown = composed()
exposed = (land & (elev_norm > 0.15) & (elev_norm < 0.65)
& (moisture < 0.30)
& (biome != 17) & (biome != 18) & (biome != 19))
biome[exposed] = EXOTIC_CLASSES["cryptobiotic_crust"]
& (shown != 17) & (shown != 18) & (shown != 19))
paint_biome(exposed, EXOTIC_CLASSES["cryptobiotic_crust"])
# Sulfuric substrate: scrub on volcanic mid-elevations
# Sulfuric substrate: scrub on volcanic mid-elevations. Unreachable as
# written — it converts rock (18) only below elev_norm 0.65, and rock exists
# only above it (T-1295; left for the balancing pass).
if substrate == "sulfuric":
scrub = land & (elev_norm > 0.25) & (elev_norm < 0.65) & (t_norm > 0.35)
biome[scrub & (biome == 18)] = EXOTIC_CLASSES["sulfuric_scrub"]
paint_biome(scrub & (composed() == 18), EXOTIC_CLASSES["sulfuric_scrub"])
# ── Anomaly scatter ─────────────────────────────────────────────────
# Sparse micro-features that break biome uniformity and tell stories.
@@ -775,7 +830,7 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
scatter_mask = (scatter_noise > 0.72) & land
if scatter_mask.any():
b_local = biome[scatter_mask]
b_local = composed()[scatter_mask]
t_local = temperature[scatter_mask]
m_local = moisture[scatter_mask]
e_local = elev_norm[scatter_mask]
@@ -805,12 +860,20 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
lava_mask = np.isin(b_local, [19, 25])
new_b[lava_mask & (t_local < 310) & (m_local > 0.30)] = 24 # lithic pioneer
biome[scatter_mask] = new_b
# Route each repainted cell to its layer: vents, ash and oasis
# water are ground; savanna, shrubland and pioneer growth are life.
changed = new_b != b_local
rows, cols = (a[changed] for a in np.nonzero(scatter_mask))
vals = new_b[changed]
ground = np.isin(vals, (19, 25, 2))
terrain[rows[ground], cols[ground]] = vals[ground]
biome[rows[~ground], cols[~ground]] = vals[~ground]
terrain[rows[~ground], cols[~ground]] = LAYER_NONE
# Vegetation ring around oasis lakes: dilate water cells from the
# scatter pass and assign graduated vegetation to the ring.
# water → coast vegetation → savanna/shrub → original biome
oasis_water = (biome == 2) & land # scattered lake cells on land
oasis_water = (composed() == 2) & land # scattered lake cells on land
if oasis_water.any():
from scipy.ndimage import binary_dilation
# Pixel-unit iteration counts scale by GRID_W/512 (module
@@ -833,11 +896,11 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
& land
)
# Inner ring: lush vegetation (coast/lowland green)
biome[ring1] = 4 # lowland
paint_biome(ring1, 4) # lowland
# Outer ring: transitional (savanna/shrub)
biome[ring2] = 12 # shrubland
paint_biome(ring2, 12) # shrubland
return biome
return biome, terrain
# ---------------------------------------------------------------------------
@@ -874,8 +937,9 @@ def simulate(body_def: dict) -> dict:
hillshade = compute_hillshade(elevation)
biome = compute_biome(
biome_layer, terrain_layer = compute_biome_layers(
body_def, elevation, sea_level, surface_water, temperature, moisture)
biome = compose_layers(biome_layer, terrain_layer)
# Normalise temperature to [0,1] for renderer display — biome already computed
t_min, t_max = temperature.min(), temperature.max()
@@ -885,7 +949,9 @@ def simulate(body_def: dict) -> dict:
"elevation": elevation,
"temperature": temperature_norm, # normalised [0,1] for renderer
"moisture": moisture,
"biome": biome,
"biome": biome, # composed — what the renders colour
"biome_layer": biome_layer, # D-258: the baked biomemap (LAYER_NONE = none)
"terrain_layer": terrain_layer, # D-258: the baked terrainmap
"surface_water": surface_water,
"hillshade": hillshade,
"sea_level": sea_level,
+22
View File
@@ -162,6 +162,28 @@ def import_heightmaps(
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, skip_rename=skip_rename))
@app.command("bake-biome")
@command
def bake_biome(
db: Path = typer.Option(None, "--db", help="systems.db path (reads the body scope)."),
body: str = typer.Option(None, "--body", help="Bake only this body."),
limit: int = typer.Option(None, "--limit", help="Stop after N bodies."),
dry_run: bool = typer.Option(False, "--dry-run", help="Count the scope, write nothing."),
check: bool = typer.Option(
False, "--check", help="Write nothing; fail if a committed map differs from a fresh bake."
),
) -> None:
"""Bake biomemap.png + terrainmap.png beside each body's heightmap (D-258).
Pre-seed: keyed on the body's own seed, never the world's. Scope is the
heightmap bake set with an atmosphere. A full bake is ~27 minutes;
`--detach` and tail it. Stage the maps under wiki/star-systems after.
"""
from tooling.domains.atlas.planet import bake_biome as impl
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, check=check))
@app.command("import-provinces")
@command
def import_provinces(
+64
View File
@@ -28,7 +28,10 @@ import numpy as np # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import ( # noqa: E402
GRID_H,
GRID_W,
LAYER_NONE,
compose_layers,
compute_biome,
compute_biome_layers,
)
MOUNTAIN_ROCK = 18
@@ -78,5 +81,66 @@ class MountainRockTests(unittest.TestCase):
self.assertEqual(int((low == MOUNTAIN_ROCK).sum()), 0)
class LayerSplitTests(unittest.TestCase):
"""D-258 ruling 6: biome and terrain are two maps (T-1295)."""
def _layers(self, atmosphere: str, water_rows: int = 0):
elev = np.full((GRID_H, GRID_W), 0.3, dtype=np.float32)
elev[:, : GRID_W // 2] = 0.95 # high west, as in MountainRockTests
water = np.zeros((GRID_H, GRID_W), dtype=bool)
if water_rows:
water[-water_rows:, :] = True # a southern sea
elev[-water_rows:, :] = 0.05
temp = np.tile(np.linspace(275.0, 305.0, GRID_W, dtype=np.float32), (GRID_H, 1))
moisture = np.full((GRID_H, GRID_W), 0.5, dtype=np.float32)
body = _body()
body["physical"]["atmosphere"] = atmosphere
body["environment"]["hydrosphere"] = "liquid_water" if water_rows else "none"
biome, terrain = compute_biome_layers(body, elev, SEA_LEVEL, water, temp, moisture)
return biome, terrain, water
def test_rock_keeps_its_biome_beneath(self):
# The point of the split: the climate class under a mountain survives.
biome, terrain, _ = self._layers("standard")
rock = terrain == MOUNTAIN_ROCK
self.assertTrue(rock.any())
self.assertFalse((biome[rock] == LAYER_NONE).any(), "rock with no biome beneath")
def test_thin_atmosphere_is_bare_ground_with_scattered_life(self):
# Thin worlds skip the Whittaker table, so their ground is terrain. The
# only life is what the anomaly scatter places (it skips airless worlds
# only): oasis savanna, shrubs, pioneers. So the biome map is sparse and
# holds nothing but those classes.
biome, terrain, _ = self._layers("thin")
placed = biome != LAYER_NONE
self.assertLess(float(placed.mean()), 0.10, "thin world carries a climate biome")
self.assertTrue(np.isin(biome[placed], (4, 7, 12, 24)).all(), set(np.unique(biome[placed])))
self.assertFalse((terrain[~placed] == LAYER_NONE).any())
def test_water_is_terrain_only(self):
biome, terrain, water = self._layers("standard", water_rows=64)
self.assertTrue((biome[water] == LAYER_NONE).all())
self.assertTrue(np.isin(terrain[water], (0, 1, 2, 26)).all())
def test_no_pixel_is_empty_on_both_maps(self):
for atmo, rows in (("standard", 0), ("standard", 64), ("thin", 0)):
biome, terrain, _ = self._layers(atmo, water_rows=rows)
both = (biome == LAYER_NONE) & (terrain == LAYER_NONE)
self.assertFalse(both.any(), f"{atmo}/{rows}: {int(both.sum())} px on neither map")
def test_stacking_the_maps_is_the_rendered_grid(self):
biome, terrain, water = self._layers("standard", water_rows=64)
composed = compose_layers(biome, terrain)
elev = np.full((GRID_H, GRID_W), 0.3, dtype=np.float32)
elev[:, : GRID_W // 2] = 0.95
elev[-64:, :] = 0.05
temp = np.tile(np.linspace(275.0, 305.0, GRID_W, dtype=np.float32), (GRID_H, 1))
body = _body()
body["environment"]["hydrosphere"] = "liquid_water"
grid = compute_biome(body, elev, SEA_LEVEL, water, temp,
np.full((GRID_H, GRID_W), 0.5, dtype=np.float32))
self.assertTrue(np.array_equal(composed, grid))
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -38,6 +38,7 @@ IMPLEMENTATIONS = {
"batch": ("batch", []),
"scaffold": ("scaffold_bodies", ["index.json"]),
"import-heightmaps": ("import_heightmaps", []),
"bake-biome": ("bake_biome", []),
"import-provinces": ("import_province_boundaries", []),
"terrain-reference": ("populate_terrain_reference", []),
"sol-import": ("sol_import", []),