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>
147 lines
6.2 KiB
Python
147 lines
6.2 KiB
Python
#!/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,
|
|
LAYER_NONE,
|
|
compose_layers,
|
|
compute_biome,
|
|
compute_biome_layers,
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
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()
|