refactor(tooling): T-1288 — planet-gen becomes reach atlas planet

The 30-file tree moves under atlas as its third rung (D-243), ten verbs
fronting it. Each verb restates its module's options so `--help` describes
something; tooling/test_planet_router.py hands every declared option to the
module's own argparse and fails on drift, and now runs in make test-tooling.

The 2026-09-02 half of this move had converted the top-level imports and the
repo roots. Finishing it found what the half-move left:

- Lazy in-function imports, and all of sol_data/, still named siblings bare.
  They resolved only through sys.path.insert hacks, so under reach the first
  globe render in generate, batch or sol-import would have raised
  ModuleNotFoundError. Qualified; the hacks are gone.
- 247 print() calls and a stdout progress writer that fired once per 8 KB
  block. Report verbs (audit, quality) write through console.out, progress
  through console.event, and download progress is throttled to 10% steps
  so a job log is not tens of thousands of lines.
- Every error exit raises ReachError with a fix.

Two checks that could not fail:

- batch --verify-determinism printed a warning and exited 0 on a mismatch.
- import-provinces exited 0 with errors > 0.

Both now raise. The 271-body bake is only safe to re-run because the first
one holds.

sol-import --body is action="append" in the module but the router took one
value, so --body GJ0d --body GJ0e kept one. Now repeatable, and _flags repeats
list options.

test_conformance walked one level, so a nested group was reported as a verb
missing @command and its ten verbs were never checked. It recurses now;
proven by stripping @command from `planet quality` and watching it fail.

Stray PNGs from the 2026-09-03 runaway router-test run are parked in
.cache/t1288-stray-pngs/, not committed. Their reliefmaps differ from HEAD
while the heightmap regenerated byte-identical — filed as T-1291.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:08:02 +02:00
co-authored by Claude Opus 5.5
parent 201dabd19b
commit 668772075c
56 changed files with 1130 additions and 509 deletions
+2 -2
View File
@@ -41,8 +41,8 @@ the registry itself) is defined once in `tooling/generator_sources.py` (T-1067)
— imported by both the importer's stamp writer and `check-systems-db-stamp`,
and listed via `python3 tooling/generator_sources.py --list`.
The surviving planet-gen importers (`import_heightmaps`,
`import_province_boundaries`) are one-time build imports baked into the
The surviving `reach atlas planet` importers (`import-heightmaps`,
`import-provinces`) are one-time build imports baked into the
committed DB — not part of `make regen-db`, and intentionally not stamped.
---
+4 -4
View File
@@ -200,11 +200,11 @@ session once already.
| tool | does |
|---|---|
| `tooling/db/wiki_sync.py` | systems.db → star-system page sections |
| `tooling/planet-gen/scaffold_bodies.py` | creates body dirs/pages |
| `tooling/planet-gen/body_definition_parser.py` | reads body frontmatter |
| `reach atlas planet scaffold` | creates body dirs/pages |
| `tooling/domains/atlas/planet/body_definition_parser.py` | reads body frontmatter |
| `tooling/db/populate_gttr_hook.py` | GTTR prose → `gttr_hook` |
| `tooling/planet-gen/populate_terrain_reference.py` | terrain asset paths |
| `tooling/planet-gen/atlas_cohesion_audit.py` | audits atlas coherence |
| `reach atlas planet terrain-reference` | terrain asset paths |
| `reach atlas planet audit` | audits atlas coherence |
| `make regen-db` | economics TOML + corp frontmatter → systems.db |
`pql` queries the vault: `pql search`, `pql backlinks`, `pql related`,
+1 -1
View File
@@ -198,7 +198,7 @@ else
fi
# --- Tooling test gate (T-1066) ---
# make test-tooling = planet-gen determinism guard (#963) + import_economics
# make test-tooling = atlas planet determinism guard (#963) + import_economics
# --dry-run validation against the committed DB. Only worth the ~90 s when the
# push actually touches tooling/ (or pyproject.toml), same scope as ruff above.
if [ "$TOOLING_CHANGED" -eq 0 ]; then
+56
View File
@@ -289,3 +289,59 @@ and 3 paths in `docs/architecture/character-asset-organization.md`.
Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks),
`test_lazy_domains` now sees 10 domains.', NULL, '2026-09-02 18:55:28', '2026-09-02 18:55:28.421', '2026-09-02 18:55:28.421', NULL, '1352ceb77da9a63b23ee98f26fa76b1e', 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 ('06G5FZDN9YBP0MZ021N3TJCFXM', 'status', 'backlog', 'done', NULL, '2026-09-02 18:55:28', '2026-09-02 18:55:28.441', '2026-09-02 18:55:28.441', NULL, 'effde7946e61e7d9e8cdbaf14324676d', 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 ('06G65NSJ4RG6SJ12TQE78S6TKR', 'description', 'The largest single port left: `tooling/planet-gen/` (30 Python files) becomes
`reach atlas planet <verb>`, the third rung of the spatial ladder (D-243), per
the nesting the domain map fixed in T-1271 — `planet` is NOT a top-level domain,
it lives under `atlas` because atlas coordinates world-location generation.
BEFORE MOVING ANYTHING, check `__file__`-relative roots. Five ports so far have
hit this and it fails SILENTLY: a script that computed `Path(__file__).parent.parent`
as the repo root resolves somewhere else once it lives two directories deeper,
and the usual symptom is a gate that reports "nothing to check, OK" and exits 0.
`validate-checklist` did exactly that. Grep for `__file__` first and convert to
`config.repo_root()` / `config.path(...)` as part of the move, not after.
WATCH the ones that are already gated:
- `test_sim_determinism.py` and `test_oasis_ring_scaling.py` are run by
`make test-tooling` and must keep working — they are tests, not verbs. Decide
whether they move with the domain or stay as test scripts, and update the
Makefile either way.
- Anything registered in `tooling/canvas_sources.py` (the canvas-generation
version gate, T-1242) — moving a path there without updating the registry
breaks `make check-canvas-version`, which has no override.
- `import_heightmaps` / `import_province_boundaries` are one-time build imports,
deliberately NOT stamped (`.claude/rules/asset-pipeline.md`). Do not fold them
into the regen path while porting.
Standard port acceptance as on T-1281/T-1286: output parity (exit codes match
exactly, no fact lost, failures name a remedy), every command carries `@command`,
nothing prints but `console`, no `subprocess` outside `core/process`.', 'The largest single port left: `tooling/planet-gen/` (30 Python files) becomes
`reach atlas planet <verb>`, the third rung of the spatial ladder (D-243), per
the nesting the domain map fixed in T-1271 — `planet` is NOT a top-level domain,
it lives under `atlas` because atlas coordinates world-location generation.
BEFORE MOVING ANYTHING, check `__file__`-relative roots. Five ports so far have
hit this and it fails SILENTLY: a script that computed `Path(__file__).parent.parent`
as the repo root resolves somewhere else once it lives two directories deeper,
and the usual symptom is a gate that reports "nothing to check, OK" and exits 0.
`validate-checklist` did exactly that. Grep for `__file__` first and convert to
`config.repo_root()` / `config.path(...)` as part of the move, not after.
WATCH the ones that are already gated:
- `test_sim_determinism.py` and `test_oasis_ring_scaling.py` are run by
`make test-tooling` and must keep working — they are tests, not verbs. Decide
whether they move with the domain or stay as test scripts, and update the
Makefile either way.
- Anything registered in `tooling/canvas_sources.py` (the canvas-generation
version gate, T-1242) — moving a path there without updating the registry
breaks `make check-canvas-version`, which has no override.
- `import_heightmaps` / `import_province_boundaries` are one-time build imports,
deliberately NOT stamped (`.claude/rules/asset-pipeline.md`). Do not fold them
into the regen path while porting.
Standard port acceptance as on T-1281/T-1286: output parity (exit codes match
exactly, no fact lost, failures name a remedy), every command carries `@command`,
nothing prints but `console`, no `subprocess` outside `core/process`.
DONE 2026-09-23. reach atlas planet: ten verbs over the moved package; tooling/test_planet_router.py (in make test-tooling) fails if a router option drifts from its module''s argparse. Finishing the 2026-09-02 half-move found: (1) lazy in-function imports and all of sol_data/ still used bare sibling names that only resolved via sys.path hacks — generate/batch/sol-import would have failed on first globe render under reach; qualified, hacks removed. (2) 247 print() + a per-8KB-block stdout progress writer routed through console (report verbs -> out, progress -> event, throttled download progress). (3) Every error exit now raises ReachError with fix=. (4) TWO CHECKS THAT COULD NOT FAIL: batch --verify-determinism and import-provinces both exited 0 on detected failures; both now raise. (5) sol-import --body is action=append but the router took one value — now repeatable. (6) test_conformance only walked one level, so the nested group''s ten verbs were never checked; now recursive, proven against a mutant. Stray PNGs from the 2026-09-03 runaway test run (8 modified reliefmaps, 6 new heightmaps) parked in .cache/t1288-stray-pngs/, reliefmaps restored from HEAD. NOTE: the reliefmaps differed from the committed bake, i.e. the current renderer no longer reproduces committed reliefmap bytes — not investigated here.', NULL, '2026-09-23 14:07:42', '2026-09-23 14:07:42.675', '2026-09-23 14:07:42.675', NULL, 'b6c6d1e211eada353630e3946da96310', 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 ('06G65NSJ4RG6SJ12TQE78S6TKR', 'status', 'backlog', 'done', NULL, '2026-09-23 14:07:43', '2026-09-23 14:07:43.036', '2026-09-23 14:07:43.036', NULL, 'e3329ba1f64d0a63c635d4a9049d56b6', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -5,3 +5,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65NSJ4RG6SJ12TQE78S6TKR', 'T-1288', '2026-09-02 15:57:45.383', '2026-09-02 15:57:45.383', NULL, 'db4b46dbe9d6484c4d618bcbc0c3d04d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65T2N1WAT491WY8SV736J3G', 'T-1289', '2026-09-02 16:16:28.431', '2026-09-02 16:16:28.431', NULL, '976a8d383d0581caafca5a4bc2e0fb61', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65TC359QGCVBEPXHJ4N0MRG', 'T-1290', '2026-09-02 16:17:45.770', '2026-09-02 16:17:45.770', NULL, '748d7ed02f4821a7ee5059a76d2fd3fe', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCX60QRMD7KJ1TVWTR7FWK28', 'T-1291', '2026-09-23 14:07:49.190', '2026-09-23 14:07:49.190', NULL, '20abfe457c23ea4e00d3c8109f2e39f2', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
+59
View File
@@ -442,3 +442,62 @@ and 3 paths in `docs/architecture/character-asset-organization.md`.
Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks),
`test_lazy_domains` now sees 10 domains.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-09-02 18:55:28.441', NULL, '41fbc2c73d4d33a6f6224cf6e97f7aac', 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 ('06G65NSJ4RG6SJ12TQE78S6TKR', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port atlas planet — the 30-file planet-gen tree', 'The largest single port left: `tooling/planet-gen/` (30 Python files) becomes
`reach atlas planet <verb>`, the third rung of the spatial ladder (D-243), per
the nesting the domain map fixed in T-1271 — `planet` is NOT a top-level domain,
it lives under `atlas` because atlas coordinates world-location generation.
BEFORE MOVING ANYTHING, check `__file__`-relative roots. Five ports so far have
hit this and it fails SILENTLY: a script that computed `Path(__file__).parent.parent`
as the repo root resolves somewhere else once it lives two directories deeper,
and the usual symptom is a gate that reports "nothing to check, OK" and exits 0.
`validate-checklist` did exactly that. Grep for `__file__` first and convert to
`config.repo_root()` / `config.path(...)` as part of the move, not after.
WATCH the ones that are already gated:
- `test_sim_determinism.py` and `test_oasis_ring_scaling.py` are run by
`make test-tooling` and must keep working — they are tests, not verbs. Decide
whether they move with the domain or stay as test scripts, and update the
Makefile either way.
- Anything registered in `tooling/canvas_sources.py` (the canvas-generation
version gate, T-1242) — moving a path there without updating the registry
breaks `make check-canvas-version`, which has no override.
- `import_heightmaps` / `import_province_boundaries` are one-time build imports,
deliberately NOT stamped (`.claude/rules/asset-pipeline.md`). Do not fold them
into the regen path while porting.
Standard port acceptance as on T-1281/T-1286: output parity (exit codes match
exactly, no fact lost, failures name a remedy), every command carries `@command`,
nothing prints but `console`, no `subprocess` outside `core/process`.
DONE 2026-09-23. reach atlas planet: ten verbs over the moved package; tooling/test_planet_router.py (in make test-tooling) fails if a router option drifts from its module''s argparse. Finishing the 2026-09-02 half-move found: (1) lazy in-function imports and all of sol_data/ still used bare sibling names that only resolved via sys.path hacks — generate/batch/sol-import would have failed on first globe render under reach; qualified, hacks removed. (2) 247 print() + a per-8KB-block stdout progress writer routed through console (report verbs -> out, progress -> event, throttled download progress). (3) Every error exit now raises ReachError with fix=. (4) TWO CHECKS THAT COULD NOT FAIL: batch --verify-determinism and import-provinces both exited 0 on detected failures; both now raise. (5) sol-import --body is action=append but the router took one value — now repeatable. (6) test_conformance only walked one level, so the nested group''s ten verbs were never checked; now recursive, proven against a mutant. Stray PNGs from the 2026-09-03 runaway test run (8 modified reliefmaps, 6 new heightmaps) parked in .cache/t1288-stray-pngs/, reliefmaps restored from HEAD. NOTE: the reliefmaps differed from the committed bake, i.e. the current renderer no longer reproduces committed reliefmap bytes — not investigated here.', 'backlog', 'high', NULL, 'tooling', 'D-263', '2026-09-02 15:57:45.382', '2026-09-23 14:07:42.675', NULL, '71f6451699ed5f0d38612fe15d1cde35', 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 ('06G65NSJ4RG6SJ12TQE78S6TKR', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port atlas planet — the 30-file planet-gen tree', 'The largest single port left: `tooling/planet-gen/` (30 Python files) becomes
`reach atlas planet <verb>`, the third rung of the spatial ladder (D-243), per
the nesting the domain map fixed in T-1271 — `planet` is NOT a top-level domain,
it lives under `atlas` because atlas coordinates world-location generation.
BEFORE MOVING ANYTHING, check `__file__`-relative roots. Five ports so far have
hit this and it fails SILENTLY: a script that computed `Path(__file__).parent.parent`
as the repo root resolves somewhere else once it lives two directories deeper,
and the usual symptom is a gate that reports "nothing to check, OK" and exits 0.
`validate-checklist` did exactly that. Grep for `__file__` first and convert to
`config.repo_root()` / `config.path(...)` as part of the move, not after.
WATCH the ones that are already gated:
- `test_sim_determinism.py` and `test_oasis_ring_scaling.py` are run by
`make test-tooling` and must keep working — they are tests, not verbs. Decide
whether they move with the domain or stay as test scripts, and update the
Makefile either way.
- Anything registered in `tooling/canvas_sources.py` (the canvas-generation
version gate, T-1242) — moving a path there without updating the registry
breaks `make check-canvas-version`, which has no override.
- `import_heightmaps` / `import_province_boundaries` are one-time build imports,
deliberately NOT stamped (`.claude/rules/asset-pipeline.md`). Do not fold them
into the regen path while porting.
Standard port acceptance as on T-1281/T-1286: output parity (exit codes match
exactly, no fact lost, failures name a remedy), every command carries `@command`,
nothing prints but `console`, no `subprocess` outside `core/process`.
DONE 2026-09-23. reach atlas planet: ten verbs over the moved package; tooling/test_planet_router.py (in make test-tooling) fails if a router option drifts from its module''s argparse. Finishing the 2026-09-02 half-move found: (1) lazy in-function imports and all of sol_data/ still used bare sibling names that only resolved via sys.path hacks — generate/batch/sol-import would have failed on first globe render under reach; qualified, hacks removed. (2) 247 print() + a per-8KB-block stdout progress writer routed through console (report verbs -> out, progress -> event, throttled download progress). (3) Every error exit now raises ReachError with fix=. (4) TWO CHECKS THAT COULD NOT FAIL: batch --verify-determinism and import-provinces both exited 0 on detected failures; both now raise. (5) sol-import --body is action=append but the router took one value — now repeatable. (6) test_conformance only walked one level, so the nested group''s ten verbs were never checked; now recursive, proven against a mutant. Stray PNGs from the 2026-09-03 runaway test run (8 modified reliefmaps, 6 new heightmaps) parked in .cache/t1288-stray-pngs/, reliefmaps restored from HEAD. NOTE: the reliefmaps differed from the committed bake, i.e. the current renderer no longer reproduces committed reliefmap bytes — not investigated here.', 'done', 'high', NULL, 'tooling', 'D-263', '2026-09-02 15:57:45.382', '2026-09-23 14:07:43.036', NULL, '2a78f8f8646d915600ed55c5dc5d0993', 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 ('06GCX60QRMD7KJ1TVWTR7FWK28', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Planet renderer no longer reproduces committed reliefmap.png bytes (8 bodies differ on re-render)', 'Found during T-1288. A 2026-09-03 accidental generate run re-rendered bodies in GJ-1002, GJ-1116B, GJ-139, GJ-147, GJ-216A, GJ-234A, GJ-273, GJ-282B: every reliefmap.png came back a few hundred bytes different from HEAD, while GJ1002b''s heightmap.png came back byte-identical (so the simulation is deterministic — make test-tooling confirms — and the drift is in the reliefmap RENDER path or its PNG encoding). Copies of the re-rendered files are in .cache/t1288-stray-pngs/. Question to answer first: is this a visible change (renderer code evolved since the April bake) or encoder-level noise (zlib/Pillow version, metadata)? Pixel-diff one pair before anything else. If visible: decide whether committed reliefmaps should be re-baked. If noise: make the encode deterministic so a re-render is a no-op in git.', 'backlog', 'low', NULL, 'tooling', NULL, '2026-09-23 14:07:49.189', '2026-09-23 14:07:49.189', NULL, '215478973901c77cdd0e6b5c446ff19e', 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;
+7 -4
View File
@@ -277,23 +277,26 @@ test-ipc-benchmark:
VENV_PY := $(shell test -x .venv/bin/python && echo .venv/bin/python || echo python3)
# Tooling test gate (T-1066) — called by pre-push when tooling/ changed.
# 1. planet-gen determinism guard (#963): simulating the same body twice must
# 1. atlas planet determinism guard (#963): simulating the same body twice must
# be bit-identical (guards the expensive 271-body heightmap bake).
# Exit 2 = no testable body found — warn, don't block.
# 2. import_economics --dry-run against the committed DB: full parse +
# structural/coverage validation, no writes. Exit 2 = coverage-gate
# warning (D-175) — tolerated, matching regen-db's treatment.
test-tooling:
@echo " [test-tooling] planet-gen determinism guard (#963)..."
@rc=0; $(VENV_PY) tooling/planet-gen/test_sim_determinism.py || rc=$$?; \
@echo " [test-tooling] atlas planet determinism guard (#963)..."
@rc=0; $(VENV_PY) tooling/test_planet_determinism.py || rc=$$?; \
if [ $$rc -eq 2 ]; then \
echo " WARNING: determinism guard found no testable body (exit 2) — not blocking"; \
elif [ $$rc -ne 0 ]; then \
echo " FAIL: planet_simulation determinism drift (exit $$rc)"; exit $$rc; \
fi
@echo " [test-tooling] oasis ring-scaling pin (T-964, PR #210 review)..."
@$(VENV_PY) tooling/planet-gen/test_oasis_ring_scaling.py 2> .cache/test-tooling-oasis-ring.log || \
@$(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] 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; }
@echo " [test-tooling] canvas-generation version gate units (T-1242)..."
@mkdir -p .cache
@python3 tooling/test_canvas_version_check.py 2> .cache/test-tooling-canvas-version.log || \
+1 -1
View File
@@ -123,7 +123,7 @@ The server must be running before the client connects (subprocess launch will be
make test # Run all tests (test-server + test-client)
make test-server # Rust tests via tests/run-rust (cargo nextest, JSON summary)
make test-client # Godot tests via tests/run-godot (gdUnit4 headless, JSON summary)
make test-tooling # Tooling gate: planet-gen determinism guard + import_economics --dry-run (T-1066)
make test-tooling # Tooling gate: atlas planet determinism guard + import_economics --dry-run (T-1066)
```
The IPC test layers (D-030) have dedicated targets:
+3 -3
View File
@@ -1,7 +1,7 @@
# Atlas Hand-Refine Log — Sprint 36
Tracks all hand-edits to markers.json files above the batch Gemma 2 pass (#833).
Re-run `python3 tooling/planet-gen/atlas_quality_analysis.py` after each entry to verify metrics improved.
Re-run `reach atlas planet quality` after each entry to verify metrics improved.
---
@@ -58,7 +58,7 @@ River and ocean collisions (Rio Grande ×23 rivers, Steinbruch ×19 rivers, Ridg
## Sprint 36 — Core-World Cohesion Pass (#849)
**Analysis date:** 2026-04-19
**Analysis script:** `tooling/planet-gen/atlas_quality_analysis.py`
**Analysis script:** `reach atlas planet quality`
### Key findings from analysis
@@ -148,7 +148,7 @@ River and ocean collisions (Rio Grande ×23 rivers, Steinbruch ×19 rivers, Ridg
- **"Jade Harbor" city on 20 bodies:** Widespread collision in generated content. The Gemma batch used this as a fallback east_reach city name. Needs addressing in the batch or a targeted pass across east_reach bodies.
- **"Rio Grande" river on 23 bodies:** Portuguese fallback. Fix in the south_reach naming templates or targeted pass.
*See also: `tooling/planet-gen/refine_log_849.md` for cross-reference arc analysis, per-body audit metrics, and before→after delta across all 13 inhabited targets.*
*See also: `docs/sprints/refine-log-T-849.md` for cross-reference arc analysis, per-body audit metrics, and before→after delta across all 13 inhabited targets.*
---
+1 -1
View File
@@ -86,7 +86,7 @@ invented.
|---|---|---|
| `check` | repo consistency gates the push hook runs | `check-client-version` ✅, `check-canvas-version`, `check-systems-db-stamp`, `check-fact-ids`, `check-dataflow-graph.py` |
| `validate` | content and schema validation | `validate-content`, `validate-checklist`, `validate-ron` |
| `atlas` | **the whole spatial ladder** (D-191). Flat verbs for authoring and inspection; nested groups per rung for generation | flat: `atlas` (Rust binary), `atlas-check`, `atlas-names`, `atlas-commit-and-sync`, `atlas-systems-done`, `atlas-update-field`, `atlas-verify`, `atlas-flatness` · `atlas map`: the 5 star-map files · `atlas planet`: `planet-gen/` (30) |
| `atlas` | **the whole spatial ladder** (D-191). Flat verbs for authoring and inspection; nested groups per rung for generation | flat: `atlas` (Rust binary), `atlas-check`, `atlas-names`, `atlas-commit-and-sync`, `atlas-systems-done`, `atlas-update-field`, `atlas-verify`, `atlas-flatness` · `atlas map`: the 5 star-map files · `atlas planet`: ✅ ported (T-1288) from `planet-gen/` (30) — ten verbs, each restating its module's options for real `--help`; `test_planet_router.py` fails if a router option drifts from the module parser |
| ~~`starmap`~~ | **folded into `atlas map`** — the top rung of the same ladder | — |
| ~~`planet`~~ | **folded into `atlas planet`** — the third rung of the same ladder | — |
| `ledger` | the economics pipeline, named for the UI component that will aggregate it | `economy-db/` (17 files), `schema_version.py` |
+22
View File
@@ -0,0 +1,22 @@
"""`atlas planet` — the third rung of the spatial ladder (D-243).
Nested under `atlas` rather than standing alone because atlas coordinates
world-location generation: the ladder is one subject, and `planet` is a rung of
it, not a peer. Same reason `atlas map` holds the star-map verbs.
Formerly `tooling/planet-gen/`, 30 files reachable only by path. The move fixed
what a hyphenated directory made impossible — these modules import each other,
and until now did it by mutating `sys.path` at import time.
**Every repo-root computation here was wrong on arrival.** The originals
computed `Path(__file__).parent / ".." / ".."`, correct while the files sat two
levels deep and silently wrong at four. That is the fifth-through-ninth
instance of this trap in the port, and it fails quietly: a gate resolves its
inputs to a directory that does not exist, finds nothing to check, and exits 0.
They now call `config.repo_root()`, which asks git.
`PLANET_DIR` (was `TOOLING_DIR`) still means "beside this code" and still
resolves correctly — the data files it addresses, `biomes.toml`,
`sol_overrides.json`, `sol_markers/` and `earth_blocklist.txt`, moved with it.
The rename is so the name stops claiming to be `tooling/`.
"""
@@ -13,19 +13,22 @@ identify names that need hand-refining:
6. Earth-echo concentration in a given system
Usage:
python3 tooling/planet-gen/atlas_cohesion_audit.py
python3 tooling/planet-gen/atlas_cohesion_audit.py --system GJ 144
python3 tooling/planet-gen/atlas_cohesion_audit.py --body GJ144d
reach atlas planet audit
reach atlas planet audit --system GJ 144
reach atlas planet audit --body GJ144d
Decisions: D-191 (atlas pipeline, corridor palettes, markers.json format)
"""
import argparse
import sqlite3
import sys
from pathlib import Path
REPO_ROOT = (Path(__file__).resolve().parent / ".." / "..").resolve()
from tooling.core import config, console
from tooling.core.errors import ReachError
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
CARDINALS = ("North", "South", "East", "West", "Northern", "Southern",
@@ -59,9 +62,9 @@ def get_conn(db_path: Path) -> sqlite3.Connection:
def section(title: str) -> None:
print(f"\n{'='*70}")
print(f" {title}")
print('='*70)
console.out(f"\n{'='*70}")
console.out(f" {title}")
console.out('='*70)
def run_all_features_for_body(conn, body_id: str) -> list[tuple[str, str]]:
@@ -107,10 +110,10 @@ def report_empty_names(conn, system_id: str | None, body_id: str | None) -> None
f"AND (name = '' OR LENGTH(name) < 2)", params
).fetchall()
for r in rows:
print(f" [{table}] {r['body_id']}/{r['local_id']}: '{r['name']}'")
console.out(f" [{table}] {r['body_id']}/{r['local_id']}: '{r['name']}'")
found += 1
if not found:
print(" None found.")
console.out(" None found.")
def report_generic_lazy(conn, system_id: str | None, body_id: str | None) -> None:
@@ -143,11 +146,11 @@ def report_generic_lazy(conn, system_id: str | None, body_id: str | None) -> Non
hits[key] = (r['body_id'], r['local_id'], r['name'], label)
for key, (bid, lid, name, lbl) in sorted(hits.items()):
print(f" [{lbl}] {bid}/{lid}: '{name}'")
console.out(f" [{lbl}] {bid}/{lid}: '{name}'")
found += 1
if not found:
print(" None found.")
console.out(" None found.")
def report_cardinals(conn, system_id: str | None, body_id: str | None) -> None:
@@ -178,12 +181,12 @@ def report_cardinals(conn, system_id: str | None, body_id: str | None) -> None:
)
if not by_body:
print(" None found.")
console.out(" None found.")
return
for bid, entries in sorted(by_body.items()):
print(f" {bid}: {len(entries)} cardinal name(s)")
console.out(f" {bid}: {len(entries)} cardinal name(s)")
for lbl, lid, name in entries:
print(f" [{lbl}] {lid}: '{name}'")
console.out(f" [{lbl}] {lid}: '{name}'")
def report_earth_echoes(conn, system_id: str | None, body_id: str | None) -> None:
@@ -215,12 +218,12 @@ def report_earth_echoes(conn, system_id: str | None, body_id: str | None) -> Non
)
if not by_body:
print(" None found.")
console.out(" None found.")
return
for bid, entries in sorted(by_body.items()):
print(f" {bid}: {len(entries)} earth-echo(s)")
console.out(f" {bid}: {len(entries)} earth-echo(s)")
for lbl, lid, name in entries:
print(f" [{lbl}] {lid}: '{name}'")
console.out(f" [{lbl}] {lid}: '{name}'")
def report_same_body_stem_dupes(conn, system_id: str | None, body_id: str | None) -> None:
@@ -251,18 +254,18 @@ def report_same_body_stem_dupes(conn, system_id: str | None, body_id: str | None
if len(items) > 1:
types = set(i[0] for i in items)
if len(types) > 1: # only flag cross-feature (different types)
print(f" {bid} stem='{stem}':")
console.out(f" {bid} stem='{stem}':")
for lbl, nm in items:
print(f" [{lbl}] '{nm}'")
console.out(f" [{lbl}] '{nm}'")
found += 1
if not found:
print(" None found.")
console.out(" None found.")
def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
section("CROSS-BODY STEM DUPLICATES WITHIN SYSTEM (same corridor)")
if not system_id:
print(" (requires --system; skipped)")
console.out(" (requires --system; skipped)")
return
bodies_q = conn.execute(
@@ -291,13 +294,13 @@ def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
for stem, hits in sorted(all_names.items()):
bodies_hit = set(h[0] for h in hits)
if len(bodies_hit) > 1:
print(f" stem='{stem}' appears in {len(bodies_hit)} bodies:")
console.out(f" stem='{stem}' appears in {len(bodies_hit)} bodies:")
for bid, label, name in hits:
print(f" {bid} [{label}] '{name}'")
console.out(f" {bid} [{label}] '{name}'")
found += 1
if not found:
print(" None found.")
console.out(" None found.")
def report_summary_score(conn, system_id: str | None, body_id: str | None) -> None:
@@ -317,7 +320,7 @@ def report_summary_score(conn, system_id: str | None, body_id: str | None) -> No
f"SELECT body_id, proper_name, population FROM bodies {where}", params
).fetchall()
print(f" {'body_id':<20} {'name':<20} {'pop':<12} cities rivers oceans mounts pois")
console.out(f" {'body_id':<20} {'name':<20} {'pop':<12} cities rivers oceans mounts pois")
for row in bodies_q:
bid = row["body_id"]
name = row["proper_name"] or ""
@@ -333,24 +336,21 @@ def report_summary_score(conn, system_id: str | None, body_id: str | None) -> No
).fetchone()[0]
counts[key] = n
print(
f" {bid:<20} {name:<20} {pop:<12,} "
console.out(f" {bid:<20} {name:<20} {pop:<12,} "
f"{counts['c']:>5} {counts['r']:>6} {counts['o']:>6} "
f"{counts['m']:>6} {counts['p']:>4}"
)
f"{counts['m']:>6} {counts['p']:>4}")
def main() -> None:
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--system", help="GJ catalog ID (e.g. 'GJ 144')")
parser.add_argument("--body", help="Body ID (e.g. 'GJ144d')")
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
args = parser.parse_args()
args = parser.parse_args(argv)
db = Path(args.db)
if not db.exists():
print(f"error: database not found at {db}", file=sys.stderr)
sys.exit(1)
raise ReachError(f"database not found at {db}", fix="make regen-db, or point --db at an existing systems.db")
conn = get_conn(db)
@@ -364,12 +364,12 @@ def main() -> None:
if row:
system_id = row["system_id"]
print("\nAtlas Cohesion Audit")
print(f" DB: {db}")
console.out("\nAtlas Cohesion Audit")
console.out(f" DB: {db}")
if system_id:
print(f" System: {system_id}")
console.out(f" System: {system_id}")
if body_id:
print(f" Body: {body_id}")
console.out(f" Body: {body_id}")
report_summary_score(conn, system_id, body_id)
report_empty_names(conn, system_id, body_id)
@@ -381,7 +381,7 @@ def main() -> None:
report_cross_body_stem_dupes(conn, system_id)
conn.close()
print("\nDone.\n")
console.out("\nDone.\n")
if __name__ == "__main__":
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
atlas_common.py shared atlas-DB utilities for the planet-gen importers.
atlas_common.py shared atlas-DB utilities for the atlas planet importers.
Extracted from the retired generate_atlas.py (D-223, #951). The atlas city/road/
river geometry *generator* was retired when authored geometry was dropped in
@@ -20,13 +20,16 @@ Decisions: D-223 (authored content as flavoured name pool), D-191 (atlas index).
import sqlite3
from pathlib import Path
from tooling.core import config
import yaml
# ---------------------------------------------------------------------------
# Paths and grid constants
# ---------------------------------------------------------------------------
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
@@ -10,10 +10,10 @@ Queries atlas_* tables in systems.db and reports on:
5. Top-stem frequency across all named features
Usage:
python3 tooling/planet-gen/atlas_quality_analysis.py [--db server/data/systems.db]
python3 tooling/planet-gen/atlas_quality_analysis.py --system GJ380
python3 tooling/planet-gen/atlas_quality_analysis.py --top-collisions 20
python3 tooling/planet-gen/atlas_quality_analysis.py --body GJ71c
reach atlas planet quality [--db server/data/systems.db]
reach atlas planet quality --system GJ380
reach atlas planet quality --top-collisions 20
reach atlas planet quality --body GJ71c
D-191 §8: markers.json is pixel-space [row, col] against 512×256.
Re-run after any hand-refine pass to verify improvements.
@@ -23,9 +23,11 @@ import argparse
import re
import sqlite3
from collections import Counter, defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
from tooling.core import config, console
REPO_ROOT = config.repo_root()
DEFAULT_DB = REPO_ROOT / "server" / "data" / "systems.db"
CARDINAL_RE = re.compile(
@@ -154,26 +156,26 @@ def run_analysis(args):
if args.body:
body_index = {k: v for k, v in body_index.items() if k == args.body}
print("=" * 70)
print("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
print(f"DB: {args.db}")
console.out("=" * 70)
console.out("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
console.out(f"DB: {args.db}")
if args.system:
print(f"Filter: system={args.system}")
console.out(f"Filter: system={args.system}")
if args.body:
print(f"Filter: body={args.body}")
print("=" * 70)
console.out(f"Filter: body={args.body}")
console.out("=" * 70)
# --- 1. Cross-body collisions ---
print("\n[ 1. CROSS-BODY NAME COLLISIONS ]")
console.out("\n[ 1. CROSS-BODY NAME COLLISIONS ]")
collisions = cross_body_collisions(conn, limit=args.top_collisions)
for feat_type, rows in collisions.items():
if rows:
print(f"\n {feat_type}:")
console.out(f"\n {feat_type}:")
for name, cnt, bodies in rows:
print(f" '{name}'{cnt} bodies: {bodies[:80]}")
console.out(f" '{name}'{cnt} bodies: {bodies[:80]}")
# --- 2. Per-body quality scores ---
print("\n[ 2. BODY QUALITY SCORES — ranked by collision % ]")
console.out("\n[ 2. BODY QUALITY SCORES — ranked by collision % ]")
reports = []
for bid, info in body_index.items():
names = all_names_by_body.get(bid, [])
@@ -186,27 +188,25 @@ def run_analysis(args):
reports.sort(key=lambda x: -x[2]["colliding_pct"])
print(f"\n {'Body':<28} {'System':<12} {'Corridor':<15} "
console.out(f"\n {'Body':<28} {'System':<12} {'Corridor':<15} "
f"{'Coll%':>6} {'Card%':>6} {'Gen':>4} {'Echo':>4}")
for bid, info, rep in reports[:30]:
print(
f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
console.out(f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
f"{rep['colliding_pct']:>6.0%} {rep['cardinal_pct']:>6.0%} "
f"{rep['generic']:>4} {rep['earth_echo']:>4}"
)
f"{rep['generic']:>4} {rep['earth_echo']:>4}")
# --- 3. Stem frequency ---
print("\n[ 3. TOP STEM FREQUENCY (first word of name) ]")
console.out("\n[ 3. TOP STEM FREQUENCY (first word of name) ]")
all_names_flat = [n for names in all_names_by_body.values() for _, n, _ in names]
for stem, cnt in stem_frequency(all_names_flat, top_n=20):
print(f" {stem:<20} {cnt}")
console.out(f" {stem:<20} {cnt}")
# --- 4. Detailed body report (if --body specified) ---
if args.body and args.body in all_names_by_body:
bid = args.body
info = body_index.get(bid, {})
names = all_names_by_body[bid]
print(f"\n[ 4. DETAILED REPORT: {bid} ({info.get('name', '?')}) ]")
console.out(f"\n[ 4. DETAILED REPORT: {bid} ({info.get('name', '?')}) ]")
c = conn.cursor()
for feat_type, name, local_id in sorted(names, key=lambda x: x[0]):
tbl = [t for t, f in FEATURE_TABLES if f == feat_type][0]
@@ -218,29 +218,29 @@ def run_analysis(args):
flag = f" *** COLLISION ×{others}" if others > 0 else ""
cardinal = " [cardinal]" if CARDINAL_RE.search(name) else ""
generic = " [generic]" if GENERIC_RE.search(name) else ""
print(f" {feat_type:<10} {local_id:<12} {name}{flag}{cardinal}{generic}")
console.out(f" {feat_type:<10} {local_id:<12} {name}{flag}{cardinal}{generic}")
# --- 5. Sol gap check ---
print("\n[ 5. SOL SYSTEM GAP CHECK ]")
console.out("\n[ 5. SOL SYSTEM GAP CHECK ]")
c = conn.cursor()
c.execute("SELECT body_id, proper_name, population FROM bodies WHERE system_id='GJ 0' AND inhabited=1")
sol_bodies = c.fetchall()
for bid, bname, pop in sol_bodies:
has_cities = bid in all_names_by_body and any(f == "city" for f, _, _ in all_names_by_body[bid])
status = "HAS DATA" if has_cities else "*** EMPTY — needs authoring"
print(f" {bid:<15} {bname or '?':<20} pop={pop or '?'} {status}")
console.out(f" {bid:<15} {bname or '?':<20} pop={pop or '?'} {status}")
conn.close()
print("\nDone.")
console.out("\nDone.")
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", default=str(DEFAULT_DB), help="Path to systems.db")
parser.add_argument("--system", help="Filter to one system (e.g. GJ380)")
parser.add_argument("--body", help="Filter to one body (e.g. GJ71c)")
parser.add_argument("--top-collisions", type=int, default=15, help="Collision list limit")
args = parser.parse_args()
args = parser.parse_args(argv)
run_analysis(args)
@@ -6,13 +6,13 @@ Walks wiki/star-systems/, scaffolds body index.md files where missing,
then generates heightmap + globe + terrain data for every body.
Usage:
python3 batch.py # full run: scaffold + generate
python3 batch.py --scaffold-only # just create body index.md files
python3 batch.py --generate-only # just render (bodies must exist)
python3 batch.py --system GJ-144 # single system
python3 batch.py --system GJ-144 --body GJ144d # single body
python3 batch.py --overrides sol.json # per-body overrides
python3 batch.py --dry-run # validate data, don't generate
reach atlas planet batch # full run: scaffold + generate
reach atlas planet batch --scaffold-only # just create body index.md files
reach atlas planet batch --generate-only # just render (bodies must exist)
reach atlas planet batch --system GJ-144 # single system
reach atlas planet batch --system GJ-144 --body GJ144d # single body
reach atlas planet batch --overrides sol.json # per-body overrides
reach atlas planet batch --dry-run # validate data, don't generate
Skips:
- GJ-0 (Sol) manual overrides required, use --system GJ-0 explicitly
@@ -26,26 +26,31 @@ Error handling:
import argparse
import json
import os
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
# Venv bootstrap
TOOLING_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import yaml
import numpy as np
from body_definition_parser import parse_system
from planet_simulation import simulate
from render_heightmap import render_heightmap
from tooling.domains.atlas.planet.body_definition_parser import parse_system
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
# Systems to skip in batch mode (require manual handling)
SKIP_SYSTEMS = {"GJ-0"}
@@ -165,7 +170,7 @@ def _scaffold_system(system_dir: Path, overrides: dict) -> list:
if not index_md.exists():
return []
from scaffold_bodies import _body_to_frontmatter, _body_prose
from tooling.domains.atlas.planet.scaffold_bodies import _body_to_frontmatter, _body_prose
body_defs = parse_system(str(index_md), overrides=overrides)
bodies_dir = system_dir / "bodies"
@@ -247,7 +252,7 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
_save_img(hmap_img, body_dir / "heightmap.png")
# Globe — atomic write
from planet_renderer import render_globe
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(bd, terrain, size=globe_size)
_save_img(globe_img, body_dir / "globe.png")
@@ -262,14 +267,14 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
np.savez_compressed(str(body_dir / "terrain.npz"), **save_dict)
# Markers
from generate import _build_markers
from tooling.domains.atlas.planet.generate import _build_markers
markers = _build_markers(bd, terrain)
_save(markers, body_dir / "markers.json",
lambda m, p: Path(p).write_text(json.dumps(m, indent=2)))
elapsed = time.time() - t0
kind = "gas" if is_gas else f"land={int((~terrain['surface_water']).sum())}"
print(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
console.event(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
return "generated"
@@ -277,7 +282,7 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Batch planet generation — all systems unattended")
parser.add_argument("--system", help="Process only this system (dir name, e.g. GJ-144)")
@@ -293,15 +298,14 @@ def main():
help="Run N random bodies twice and verify identical output")
parser.add_argument("--heightmap-size", default="1024x512")
parser.add_argument("--globe-size", type=int, default=512)
args = parser.parse_args()
args = parser.parse_args(argv)
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
wiki_systems = WORKTREE_ROOT / "wiki" / "star-systems"
if not wiki_systems.exists():
print(f"error: {wiki_systems} not found", file=sys.stderr)
sys.exit(1)
raise ReachError(f"{wiki_systems} not found", fix="run from a settled-reach checkout — make reach-repoint")
overrides = {}
if args.overrides:
@@ -312,8 +316,7 @@ def main():
if args.system:
system_dirs = [wiki_systems / args.system]
if not system_dirs[0].exists():
print(f"error: system {args.system} not found", file=sys.stderr)
sys.exit(1)
raise ReachError(f"system {args.system} not found", fix="--system takes the wiki directory name, e.g. GJ-1002 (see wiki/star-systems/)")
else:
system_dirs = sorted([
d for d in wiki_systems.iterdir()
@@ -334,19 +337,18 @@ def main():
total_invalid = 0
error_rate_threshold = 0.50
print("\n Planet Generator — Batch Mode")
print(f" Systems: {len(system_dirs)}")
print(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
console.event(" Planet Generator — Batch Mode")
console.event(f" Systems: {len(system_dirs)}")
console.event(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
if args.dry_run:
print(" Mode: DRY RUN (validation only)")
print()
console.event(" Mode: DRY RUN (validation only)")
for system_dir in system_dirs:
system_id = system_dir.name
# Skip Sol in batch mode (needs manual overrides)
if system_id in SKIP_SYSTEMS and not args.system:
print(f" {system_id} — skipped (manual)")
console.event(f" {system_id} — skipped (manual)")
continue
system_name = _read_system_name(system_dir / "index.md")
@@ -364,7 +366,7 @@ def main():
except Exception:
n_bodies = 0
print(f" {system_id}{system_name} ({n_bodies} bodies)")
console.event(f" {system_id}{system_name} ({n_bodies} bodies)")
# ── Scaffold ─────────────────────────────────────────────────────
if not args.generate_only and not args.dry_run:
@@ -373,10 +375,10 @@ def main():
if created:
total_scaffolded += len(created)
for bid in created:
print(f" scaffolded {bid}")
console.event(f" scaffolded {bid}")
except Exception as e:
tb = traceback.format_exc()
print(f" SCAFFOLD ERROR: {e}")
console.event(f" SCAFFOLD ERROR: {e}")
_log_error(system_id, "*", str(e), tb)
# ── Validate / Generate ──────────────────────────────────────────
@@ -412,12 +414,12 @@ def main():
errors = _validate_body_def(bd, body_dir)
if errors:
total_invalid += 1
print(f" {body_id:20s} ({body_name:20s}) INVALID")
console.event(f" {body_id:20s} ({body_name:20s}) INVALID")
for err in errors:
print(f" - {err}")
console.event(f" - {err}")
else:
total_valid += 1
print(f" {body_id:20s} ({body_name:20s}) ok")
console.event(f" {body_id:20s} ({body_name:20s}) ok")
continue
if args.scaffold_only:
@@ -437,30 +439,30 @@ def main():
except Exception as e:
total_errors += 1
tb = traceback.format_exc()
print(f" {body_id:20s} ({body_name:20s}) ERROR: {e}")
console.event(f" {body_id:20s} ({body_name:20s}) ERROR: {e}")
_log_error(system_id, body_id, str(e), tb)
# Circuit breaker: abort if error rate is too high
if total_attempted >= 10 and total_errors / total_attempted > error_rate_threshold:
print(f"\n ABORT: error rate {total_errors}/{total_attempted} "
console.event(f" ABORT: error rate {total_errors}/{total_attempted} "
f"({total_errors/total_attempted*100:.0f}%) exceeds "
f"{error_rate_threshold*100:.0f}% threshold")
print(f" Check {LOG_PATH} for details")
console.event(f" Check {LOG_PATH} for details")
sys.exit(1)
elapsed = time.time() - t_total
print(f"\n Batch complete: {elapsed:.0f}s")
console.event(f" Batch complete: {elapsed:.0f}s")
if args.dry_run:
print(f" valid: {total_valid}")
print(f" invalid: {total_invalid}")
console.event(f" valid: {total_valid}")
console.event(f" invalid: {total_invalid}")
else:
print(f" scaffolded: {total_scaffolded}")
print(f" generated: {total_generated}")
print(f" skipped: {total_skipped}")
print(f" errors: {total_errors}")
console.event(f" scaffolded: {total_scaffolded}")
console.event(f" generated: {total_generated}")
console.event(f" skipped: {total_skipped}")
console.event(f" errors: {total_errors}")
if total_errors > 0:
print(f" error log: {LOG_PATH}")
console.event(f" error log: {LOG_PATH}")
# ── Determinism verification ─────────────────────────────────────────
if args.verify_determinism > 0:
@@ -475,7 +477,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
import tempfile
import shutil
print(f"\n Determinism verification — {n_samples} samples")
console.event(f" Determinism verification — {n_samples} samples")
# Collect all body dirs that have been generated
all_body_dirs = []
@@ -487,7 +489,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
all_body_dirs.append(bd)
if not all_body_dirs:
print(" no generated bodies to verify")
console.event(" no generated bodies to verify")
return
rng = np.random.default_rng(42)
@@ -514,7 +516,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
try:
_generate_body_from_dir(tmp_body, hmap_w, hmap_h, globe_size, force=True)
except Exception as e:
print(f" {body_id}: generation failed — {e}")
console.event(f" {body_id}: generation failed — {e}")
shutil.rmtree(tmp_dir)
failed += 1
continue
@@ -527,13 +529,13 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
if not orig.exists() and not rerun.exists():
continue
if not orig.exists() or not rerun.exists():
print(f" {body_id}: {fname} — missing in {'original' if not orig.exists() else 'rerun'}")
console.event(f" {body_id}: {fname} — missing in {'original' if not orig.exists() else 'rerun'}")
all_match = False
continue
h1 = hashlib.sha256(orig.read_bytes()).hexdigest()[:16]
h2 = hashlib.sha256(rerun.read_bytes()).hexdigest()[:16]
if h1 != h2:
print(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
console.event(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
all_match = False
if all_match:
@@ -543,9 +545,14 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
shutil.rmtree(tmp_dir)
print(f" passed: {passed} failed: {failed}")
console.event(f" passed: {passed} failed: {failed}")
if failed > 0:
print(" WARNING: non-deterministic output detected!")
# Was a printed warning with exit 0 — a determinism check that could not
# fail (T-1288). The 271-body bake is only re-runnable because this holds.
raise ReachError(
f"non-deterministic output: {failed} of {passed + failed} bodies differ on re-run",
fix="reach atlas planet batch --verify-determinism 1, then diff the MISMATCH files above",
)
if __name__ == "__main__":
@@ -6,7 +6,7 @@ parameters. All three pipeline modules import from here instead of
maintaining their own hardcoded tables.
Usage:
from biome_config import (
from tooling.domains.atlas.planet.biome_config import (
WHITTAKER_TABLE, CLASS_T_BAND, BIOME_PALETTE,
STAR_TINTS, ATMO_COLORS, GAS_PALETTES,
EXOTIC_CLASSES, CRATER_SCALING, RIVER_RGB, COAST_RGB,
@@ -21,10 +21,10 @@ Field resolution order (highest wins):
5. randomised (seeded, within planet-class constraints)
Usage:
python3 body_definition_parser.py path/to/index.md [--out-dir ./defs]
python -m tooling.domains.atlas.planet.body_definition_parser path/to/index.md [--out-dir ./defs]
# With overrides (e.g. Sol)
python3 body_definition_parser.py sol/index.md --overrides sol_overrides.json
python -m tooling.domains.atlas.planet.body_definition_parser sol/index.md --overrides sol_overrides.json
Override file format:
{
@@ -206,7 +206,8 @@ CLASS_OBLATENESS = {
}
# Gas giant band palettes available
from biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
from tooling.domains.atlas.planet.biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
from tooling.core import console
# planet_class → cloud coverage base range
CLASS_CLOUD = {
@@ -855,4 +856,4 @@ if __name__ == "__main__":
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
if args.print:
print(json.dumps(defs, indent=2))
console.out(json.dumps(defs, indent=2))
@@ -20,21 +20,26 @@ Optional (spike/review only):
import argparse
import json
import os
import sys
import time
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
from tooling.core import config, console
from tooling.core.errors import ReachError
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
from planet_simulation import simulate
from render_heightmap import render_heightmap
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
def _build_markers(body_def: dict, terrain: dict) -> dict:
@@ -131,7 +136,7 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
planet_class = body_def.get("planet_class", "unknown")
name = body_def.get("name") or body_id
print(f"\n {body_id} ({name}) — {planet_class}")
console.event(f" {body_id} ({name}) — {planet_class}")
t0 = time.time()
@@ -141,9 +146,9 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
t_sim = time.time()
if is_gas:
print(f" simulate: gas giant ({t_sim - t0:.1f}s)")
console.event(f" simulate: gas giant ({t_sim - t0:.1f}s)")
else:
print(f" simulate: {t_sim - t0:.1f}s "
console.event(f" simulate: {t_sim - t0:.1f}s "
f"sea={terrain['sea_level']:.3f} "
f"land={int((~terrain['surface_water']).sum())}")
@@ -163,20 +168,20 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
render_mode=render_mode, chrome=True)
chrome_path = f"/tmp/{body_id}_heightmap_chrome.png"
hmap_chrome.save(chrome_path)
print(f" chrome: {chrome_path}")
console.event(f" chrome: {chrome_path}")
t_hmap = time.time()
print(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
console.event(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
# ── 3. Render globe ──────────────────────────────────────────────────
try:
from planet_renderer import render_globe
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(body_def, terrain, size=globe_size)
globe_img.save(os.path.join(body_dir, "globe.png"))
t_globe = time.time()
print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
except Exception as e:
print(f" globe: FAILED — {e}")
console.event(f" globe: FAILED — {e}")
t_globe = time.time()
# ── 4. Write data files ──────────────────────────────────────────────
@@ -198,10 +203,10 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
json.dump(markers, f, indent=2)
elapsed = time.time() - t0
print(f" total: {elapsed:.1f}s → {body_dir}/")
console.event(f" total: {elapsed:.1f}s → {body_dir}/")
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Planet generator — heightmap + globe from body definitions")
@@ -227,28 +232,27 @@ def main():
parser.add_argument("--chrome", action="store_true",
help="Also render heightmap with title/legend (review only, not shipped)")
args = parser.parse_args()
args = parser.parse_args(argv)
# Parse heightmap size
try:
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
except ValueError:
print(f"error: invalid heightmap size '{args.heightmap_size}'", file=sys.stderr)
sys.exit(1)
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
# ── Collect body definitions ─────────────────────────────────────────
body_defs = []
if args.system:
# Read from system index.md → parse bodies table
from body_definition_parser import parse_system
from tooling.domains.atlas.planet.body_definition_parser import parse_system
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
body_defs = parse_system(args.system, overrides=overrides)
print(f"System: {args.system}{len(body_defs)} bodies")
console.event(f"System: {args.system}{len(body_defs)} bodies")
elif args.body_def:
input_path = args.body_def
if input_path.endswith(".json"):
@@ -269,15 +273,11 @@ def main():
if args.output_dir == ".":
args.output_dir = str(Path(input_path).parent)
else:
print(f"error: {input_path} frontmatter missing 'id' or 'planet_class'",
file=sys.stderr)
sys.exit(1)
raise ReachError(f"{input_path} frontmatter missing 'id' or 'planet_class'", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
else:
print(f"error: {input_path} has no YAML frontmatter", file=sys.stderr)
sys.exit(1)
raise ReachError(f"{input_path} has no YAML frontmatter", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
else:
print(f"error: unrecognized input format: {input_path}", file=sys.stderr)
sys.exit(1)
raise ReachError(f"unrecognized input format: {input_path}", fix="pass a body definition .json or a body index.md")
else:
parser.error("Provide a body_def (.json or .md) or --system index.md")
@@ -288,7 +288,7 @@ def main():
args.render_mode, args.output_dir, chrome=args.chrome)
elapsed = time.time() - t_total
print(f"\n All done: {len(body_defs)} bodies in {elapsed:.1f}s")
console.event(f" All done: {len(body_defs)} bodies in {elapsed:.1f}s")
if __name__ == "__main__":
@@ -18,9 +18,9 @@ only its display file is renamed.
No systems.db writes: the PNG is the store (the atlas_body_heightmaps BLOB
table is dropped). Run with uv (numpy/scipy/Pillow):
uv run python tooling/planet-gen/import_heightmaps.py # full bake
uv run python tooling/planet-gen/import_heightmaps.py --limit 3 # smoke test
uv run python tooling/planet-gen/import_heightmaps.py --dry-run
reach atlas planet import-heightmaps # full bake
reach atlas planet import-heightmaps --limit 3 # smoke test
reach atlas planet import-heightmaps --dry-run
Exit codes: 0 = completed (possibly with per-body errors), 1 = fatal.
"""
@@ -32,17 +32,20 @@ import sys
import time
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
sys.path.insert(0, str(TOOLING_DIR))
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import simulate # noqa: E402
from tooling.domains.atlas.planet.render_heightmap import render_heightmap # noqa: E402
from body_definition_parser import parse_system # noqa: E402
from planet_simulation import simulate # noqa: E402
from render_heightmap import render_heightmap # noqa: E402
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
@@ -89,7 +92,7 @@ def bake_body(bd: dict, body_dir: Path, dry_run: bool) -> dict:
return {"status": "baked", "shape": elevation.shape, "sea_level": sea_level}
def main() -> None:
def main(argv: list[str] | None = None) -> None:
ap = argparse.ArgumentParser(description="Bake canonical heightmap/reliefmap assets (#963)")
ap.add_argument("--db", default=str(DB_PATH))
ap.add_argument("--body", help="Bake only this body_id")
@@ -100,17 +103,16 @@ def main() -> None:
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
print("\n Heightmap bake (#963, D-202 amended)")
console.event(" Heightmap bake (#963, D-202 amended)")
if args.dry_run:
print(" Mode: DRY RUN (no files written)")
console.event(" Mode: DRY RUN (no files written)")
# 1. Universal rename of the legacy color heightmap.png → reliefmap.png.
if not args.skip_rename:
n_renamed = rename_legacy_heightmaps(args.dry_run)
print(f" Renamed legacy heightmap.png → reliefmap.png: {n_renamed} bodies")
console.event(f" Renamed legacy heightmap.png → reliefmap.png: {n_renamed} bodies")
# 2. Bake non-Sol inhabited bodies.
conn = sqlite3.connect(str(db_path))
@@ -125,7 +127,7 @@ def main() -> None:
if args.limit:
rows = rows[: args.limit]
print(f" Baking {len(rows)} non-Sol inhabited bodies\n")
console.event(f" Baking {len(rows)} non-Sol inhabited bodies\n")
parsed: dict[str, list] = {}
t0 = time.time()
@@ -137,12 +139,12 @@ def main() -> None:
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
except Exception as exc: # noqa: BLE001
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
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:
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
n_err += 1
continue
ts = time.time()
@@ -151,16 +153,16 @@ def main() -> None:
if res["status"] == "baked":
n_baked += 1
if (i + 1) % 25 == 0 or args.limit:
print(f" [{i+1}/{len(rows)}] {body_id:18s} baked {res['shape']} "
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} baked {res['shape']} "
f"sea={res['sea_level']:.3f} ({dt:.1f}s)")
elif res["status"] == "gas_giant":
n_gas += 1
else:
n_err += 1
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
print(f"\n Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
print(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
console.event(f" Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
console.event(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
if n_err:
sys.exit(0) # per-body errors are non-fatal; reported above
@@ -32,10 +32,10 @@ Incremental: bodies that already have rows in atlas_province_boundaries are skip
unless --force is passed.
Usage:
tooling/planet-gen/import_province_boundaries.py
tooling/planet-gen/import_province_boundaries.py --body GJ380c
tooling/planet-gen/import_province_boundaries.py --force
tooling/planet-gen/import_province_boundaries.py --dry-run
reach atlas planet import-provinces
reach atlas planet import-provinces --body GJ380c
reach atlas planet import-provinces --force
reach atlas planet import-provinces --dry-run
Exit codes:
0 completed
@@ -44,22 +44,25 @@ Exit codes:
import argparse
import json
import sys
import time
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
from tooling.core import config, console
from tooling.core.errors import ReachError
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
import os
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
import sqlite3
from atlas_common import (
from tooling.domains.atlas.planet.atlas_common import (
DB_PATH,
ensure_atlas_schema,
query_inhabited_bodies,
@@ -441,7 +444,7 @@ def import_body_provinces(
if verbose:
areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins]
print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
console.event(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
if not dry_run:
with conn: # per-body transaction (BEGIN/COMMIT): rolls back this body on exception, keeps prior commits
@@ -460,7 +463,7 @@ def import_body_provinces(
return {"status": "imported", "imported": len(basins)}
def main() -> None:
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Pre-compute province boundaries from watershed analysis (D-205, #907)"
)
@@ -472,20 +475,18 @@ def main() -> None:
help="Analyse without writing to DB")
parser.add_argument("--verbose", action="store_true",
help="Print per-body detail")
args = parser.parse_args()
args = parser.parse_args(argv)
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
print("\n Province Boundary Import (#907)")
print(f" DB: {db_path}")
console.event(" Province Boundary Import (#907)")
console.event(f" DB: {db_path}")
if args.dry_run:
print(" Mode: DRY RUN (no DB writes)")
console.event(" Mode: DRY RUN (no DB writes)")
if args.force:
print(" Force: enabled (will overwrite existing rows)")
print()
console.event(" Force: enabled (will overwrite existing rows)")
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys=ON")
@@ -495,12 +496,10 @@ def main() -> None:
if args.body:
bodies = [b for b in bodies if b["body_id"] == args.body]
if not bodies:
print(f"error: body '{args.body}' not found or has no terrain_reference",
file=sys.stderr)
conn.close()
sys.exit(1)
raise ReachError(f"body '{args.body}' not found or has no terrain_reference", fix="reach atlas planet terrain-reference, then retry")
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
console.event(f" {len(bodies)} inhabited bodies with terrain_reference\n")
t_total = time.time()
n_imported = 0
@@ -518,28 +517,28 @@ def main() -> None:
if status == "imported":
n_imported += 1
print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
elif status == "skipped":
n_skipped += 1
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
elif status == "no_heightmap":
n_no_hmap += 1
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
elif status == "error":
n_errors += 1
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
conn.close()
elapsed_total = time.time() - t_total
print(f"\n Done in {elapsed_total:.1f}s")
print(f" imported={n_imported} skipped={n_skipped} "
console.event(f" Done in {elapsed_total:.1f}s")
console.event(f" imported={n_imported} skipped={n_skipped} "
f"no_heightmap={n_no_hmap} errors={n_errors}")
if n_errors > 0:
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr)
raise ReachError(f"{n_errors} error(s) — check output above", fix="re-run the failing body alone: reach atlas planet import-provinces --body <id> --verbose")
if __name__ == "__main__":
@@ -25,7 +25,7 @@ Outputs:
PIL Image (RGBA, 2048×2048) caller saves as PNG
Usage:
from planet_renderer import render_globe
from tooling.domains.atlas.planet.planet_renderer import render_globe
img = render_globe(body_def, terrain=None)
img.save("myplanet.png")
@@ -43,13 +43,14 @@ import math
import numpy as np
from PIL import Image
from biome_config import (
from tooling.domains.atlas.planet.biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
STAR_TINTS as _STAR_TINTS_CFG,
ATMO_COLORS as _ATMO_COLORS_CFG,
GAS_PALETTES as _GAS_PALETTES_CFG,
MAX_BIOME_ID,
)
from tooling.core import console
# ---------------------------------------------------------------------------
# Output resolution
@@ -969,7 +970,7 @@ if __name__ == "__main__":
out = os.path.join(out_dir, f"{bd['id']}.png")
img.save(out, format="PNG")
dt = time.time() - t0
print(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
console.event(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
paths.append(out)
print(f"\nDone. {len(paths)} planets rendered at {qa_size}px.")
console.event(f"Done. {len(paths)} planets rendered at {qa_size}px.")
@@ -35,9 +35,10 @@ import math
import numpy as np
from scipy.ndimage import gaussian_filter
from biome_config import (
from tooling.domains.atlas.planet.biome_config import (
WHITTAKER_TABLE, CLASS_T_BAND, EXOTIC_CLASSES, CRATER_SCALING,
)
from tooling.core import console
log = logging.getLogger(__name__)
# Canonical heightmap grid (D-202 amended, #963): bumped to 1024×512 so the
@@ -902,7 +903,7 @@ if __name__ == "__main__":
from PIL import Image
if len(sys.argv) < 2:
print("Usage: python3 planet_simulation.py body_def.json [--save-grids]")
console.event("Usage: python -m tooling.domains.atlas.planet.planet_simulation body_def.json [--save-grids]")
sys.exit(1)
with open(sys.argv[1]) as f:
@@ -910,21 +911,21 @@ if __name__ == "__main__":
save_grids = "--save-grids" in sys.argv
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
console.event(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
if not terrain:
print("Gas giant — no terrain simulation.")
console.event("Gas giant — no terrain simulation.")
sys.exit(0)
dt = time.time() - t0
print(f"Done in {dt:.1f}s")
print(f" sea_level: {terrain['sea_level']:.3f}")
print(f" land cells: {(~terrain['surface_water']).sum()}")
console.event(f"Done in {dt:.1f}s")
console.event(f" sea_level: {terrain['sea_level']:.3f}")
console.event(f" land cells: {(~terrain['surface_water']).sum()}")
ids, counts = np.unique(terrain['biome'], return_counts=True)
print(f" biomes: {list(zip(ids.tolist(), counts.tolist()))}")
console.event(f" biomes: {list(zip(ids.tolist(), counts.tolist()))}")
if save_grids:
out = f"/tmp/{bd['id']}_grids"
@@ -933,4 +934,4 @@ if __name__ == "__main__":
arr = terrain[name]
Image.fromarray((arr * 255).astype("uint8"), "L").save(
f"{out}/{name}.png")
print(f"Grids saved → {out}/")
console.event(f"Grids saved → {out}/")
@@ -13,9 +13,9 @@ Bodies with missing heightmaps are logged to stdout for remediation.
This is the prerequisite for the atlas importers (import_heightmaps.py, #901).
Usage:
python3 tooling/planet-gen/populate_terrain_reference.py
python3 tooling/planet-gen/populate_terrain_reference.py --dry-run
python3 tooling/planet-gen/populate_terrain_reference.py --db path/to/systems.db
reach atlas planet terrain-reference
reach atlas planet terrain-reference --dry-run
reach atlas planet terrain-reference --db path/to/systems.db
Decisions: D-191 (atlas pipeline prerequisites)
"""
@@ -24,8 +24,11 @@ import argparse
import sqlite3
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = (SCRIPT_DIR / ".." / "..").resolve()
from tooling.core import config, console
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_DIR = REPO_ROOT / "wiki" / "star-systems"
@@ -53,7 +56,7 @@ def relative_terrain_reference(system_id: str, body_id: str) -> str:
return f"wiki/star-systems/{system_slug(system_id)}/bodies/{body_id}/heightmap.png"
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Populate terrain_reference column in systems.db bodies table"
)
@@ -63,11 +66,11 @@ def main():
action="store_true",
help="Report without writing changes",
)
args = parser.parse_args()
args = parser.parse_args(argv)
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found")
console.event(f"error: {db_path} not found")
raise SystemExit(1)
conn = sqlite3.connect(str(db_path))
@@ -81,11 +84,11 @@ def main():
ORDER BY b.system_id, b.body_id
""").fetchall()
print("\n terrain_reference population pass")
print(f" DB: {db_path}")
console.event(" terrain_reference population pass")
console.event(f" DB: {db_path}")
if args.dry_run:
print(" Mode: DRY RUN")
print(f"\n {len(rows)} bodies with NULL terrain_reference\n")
console.event(" Mode: DRY RUN")
console.event(f" {len(rows)} bodies with NULL terrain_reference\n")
found = []
missing = []
@@ -99,16 +102,15 @@ def main():
# Report missing heightmaps before writing — helps flag gaps early.
if missing:
print(f" MISSING heightmaps ({len(missing)} bodies — no update for these):")
console.event(f" MISSING heightmaps ({len(missing)} bodies — no update for these):")
for body_id, system_id, path in missing:
print(f" {body_id} ({system_id}) → {path}")
print()
console.event(f" {body_id} ({system_id}) → {path}")
if found:
print(f" Updating {len(found)} bodies with terrain_reference:")
console.event(f" Updating {len(found)} bodies with terrain_reference:")
for body_id, system_id in found:
ref = relative_terrain_reference(system_id, body_id)
print(f" {body_id} ({system_id}) → {ref}")
console.event(f" {body_id} ({system_id}) → {ref}")
if not args.dry_run:
conn.execute(
"UPDATE bodies SET terrain_reference = ? WHERE body_id = ?",
@@ -117,22 +119,22 @@ def main():
if not args.dry_run:
conn.commit()
print(f"\n Committed {len(found)} terrain_reference updates.")
console.event(f" Committed {len(found)} terrain_reference updates.")
else:
print("\n Dry run — no changes written.")
console.event(" Dry run — no changes written.")
conn.close()
# Summary
print("\n Summary:")
print(f" Updated: {len(found)}")
print(f" Missing: {len(missing)}")
print(f" Total: {len(rows)}\n")
console.event(" Summary:")
console.event(f" Updated: {len(found)}")
console.event(f" Missing: {len(missing)}")
console.event(f" Total: {len(rows)}\n")
if missing:
print(f" Action required: generate heightmaps for {len(missing)} bodies "
console.event(f" Action required: generate heightmaps for {len(missing)} bodies "
f"before running the atlas importers (#901).")
print(" Use: make generate-terrain (or run generate.py per body)\n")
console.event(" Use: make generate-terrain (or run generate.py per body)\n")
if __name__ == "__main__":
@@ -27,9 +27,9 @@ Geographic only. No settlements, roads, or cultural data.
Those live in a separate JSON sidecar and are overlaid by the atlas app.
Usage:
from render_heightmap import render_heightmap
from planet_simulation import simulate
from body_definition_parser import parse_system
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.body_definition_parser import parse_system
defs = parse_system("index.md")
terrain = simulate(defs[0])
@@ -41,12 +41,13 @@ import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy.ndimage import binary_dilation
from biome_config import (
from tooling.domains.atlas.planet.biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
RIVER_RGB as _RIVER_RGB_CFG,
COAST_RGB as _COAST_RGB_CFG,
build_biome_rgb,
)
from tooling.core import console
# ---------------------------------------------------------------------------
# Output resolution
@@ -423,7 +424,7 @@ if __name__ == "__main__":
import time
if len(sys.argv) < 2:
print("Usage: python3 render_heightmap.py body_def.json [--large]")
console.event("Usage: python -m tooling.domains.atlas.planet.render_heightmap body_def.json [--large]")
sys.exit(1)
with open(sys.argv[1]) as f:
@@ -433,23 +434,23 @@ if __name__ == "__main__":
large = "--large" in sys.argv
w, h = (4096, 2048) if large else (OUT_W, OUT_H)
from planet_simulation import simulate
from tooling.domains.atlas.planet.planet_simulation import simulate
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
console.event(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
sim_t = time.time() - t0
if not terrain:
print("Gas giant — no heightmap.")
console.event("Gas giant — no heightmap.")
sys.exit(0)
print(f"Rendering heightmap {w}×{h}")
console.event(f"Rendering heightmap {w}×{h}")
t1 = time.time()
img = render_heightmap(bd, terrain, out_w=w, out_h=h)
ren_t = time.time() - t1
out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png"
img.save(out, format="PNG")
print(f"Saved: {out}")
print(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
console.event(f"Saved: {out}")
console.event(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
+261
View File
@@ -0,0 +1,261 @@
"""Transport for `atlas planet` — args in, delegate, format out.
Every verb here declares its options explicitly rather than forwarding an
opaque argument list. That is deliberate and it is the more expensive option:
the underlying modules already parse their own arguments, so this restates ten
surfaces that exist elsewhere.
It is worth it because the primary user of reach is an agent (D-263), and an
agent discovers a command by reading its `--help`. A passthrough would make
`reach atlas planet generate --help` describe nothing, and the real surface
would only be findable by reading the module — which is the fragmentation the
whole CLI exists to end.
The cost is a second place that can drift. `tooling/test_planet_router.py`
closes that: it hands every declared option to the module's own parser and
fails if the parser does not recognise it.
"""
from __future__ import annotations
from pathlib import Path
import typer
from tooling.core import cli
from tooling.core.command import command
app = cli.domain("planet", "Bodies — scaffold, generate, import and audit.")
@app.callback()
def _rung() -> None:
"""Keeps `planet` a group (Typer collapses a single-command app)."""
def _flags(**pairs: object) -> list[str]:
"""Turn declared options into the argv the module's parser expects.
None means "not given" and is dropped, so the module's own defaults stay
authoritative — restating them here would be a second source of truth for
every default in the domain. A list repeats the flag, for the parsers that
declare `action="append"`.
"""
argv: list[str] = []
for name, value in pairs.items():
flag = "--" + name.replace("_", "-")
if value is None or value is False:
continue
if value is True:
argv.append(flag)
elif isinstance(value, (list, tuple)):
for item in value:
argv += [flag, str(item)]
else:
argv += [flag, str(value)]
return argv
# --- generation -----------------------------------------------------------
@app.command("generate")
@command
def generate(
body_def: Path = typer.Argument(..., help="Body definition JSON."),
system: str = typer.Option(None, "--system", help="System id to generate for."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
output_dir: Path = typer.Option(None, "--output-dir", help="Where to write output."),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
render_mode: str = typer.Option(None, "--render-mode", help="Renderer mode."),
chrome: bool = typer.Option(False, "--chrome", help="Draw chrome on the render."),
) -> None:
"""Generate one body — simulate, render the heightmap, write the globe."""
from tooling.domains.atlas.planet import generate as impl
impl.main([
str(body_def),
*_flags(
system=system,
overrides=overrides,
output_dir=output_dir,
heightmap_size=heightmap_size,
globe_size=globe_size,
render_mode=render_mode,
chrome=chrome,
),
])
@app.command("batch")
@command
def batch(
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
scaffold_only: bool = typer.Option(False, "--scaffold-only", help="Scaffold, do not generate."),
generate_only: bool = typer.Option(False, "--generate-only", help="Generate, do not scaffold."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
force: bool = typer.Option(False, "--force", help="Regenerate what already exists."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan, write nothing."),
verify_determinism: int = typer.Option(
None, "--verify-determinism", metavar="N",
help="Generate N random bodies twice and verify the output is identical.",
),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
) -> None:
"""Generate every system unattended — the long one; consider --detach."""
from tooling.domains.atlas.planet import batch as impl
impl.main(_flags(
system=system,
body=body,
scaffold_only=scaffold_only,
generate_only=generate_only,
overrides=overrides,
force=force,
dry_run=dry_run,
verify_determinism=verify_determinism,
heightmap_size=heightmap_size,
globe_size=globe_size,
))
@app.command("scaffold")
@command
def scaffold(
system_index: Path = typer.Argument(..., help="System index JSON."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan, write nothing."),
) -> None:
"""Write body definitions for a system, ready for `generate`."""
from tooling.domains.atlas.planet import scaffold_bodies as impl
impl.main([str(system_index), *_flags(overrides=overrides, dry_run=dry_run)])
# --- imports into the atlas DB -------------------------------------------
@app.command("import-heightmaps")
@command
def import_heightmaps(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
limit: int = typer.Option(None, "--limit", help="Stop after N bodies."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
skip_rename: bool = typer.Option(False, "--skip-rename", help="Do not rename source files."),
) -> None:
"""Load heightmap grids into atlas_body_heightmaps.
A one-time build import, deliberately NOT part of `make regen-db` and not
stamped (.claude/rules/asset-pipeline.md). Running it is a decision, not a
step in the pipeline.
"""
from tooling.domains.atlas.planet import import_heightmaps as impl
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, skip_rename=skip_rename))
@app.command("import-provinces")
@command
def import_provinces(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
force: bool = typer.Option(False, "--force", help="Recompute bodies that already have rows."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
verbose: bool = typer.Option(False, "--verbose", help="Per-body detail."),
) -> None:
"""Derive province boundaries from watershed analysis (D-205, D-208).
The other one-time build import — same standing as import-heightmaps.
Expect ~1520 minutes for a full run; `--detach` and tail it.
"""
from tooling.domains.atlas.planet import import_province_boundaries as impl
impl.main(_flags(db=db, body=body, force=force, dry_run=dry_run, verbose=verbose))
@app.command("terrain-reference")
@command
def terrain_reference(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
) -> None:
"""Populate the terrain reference table."""
from tooling.domains.atlas.planet import populate_terrain_reference as impl
impl.main(_flags(db=db))
# --- Sol ------------------------------------------------------------------
@app.command("sol-import")
@command
def sol_import(
body: list[str] = typer.Option(None, "--body", help="Limit to these Sol bodies (repeatable)."),
download_only: bool = typer.Option(False, "--download-only", help="Fetch source data only."),
output_dir: Path = typer.Option(None, "--output-dir", help="Where to write output."),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
render_mode: str = typer.Option(None, "--render-mode", help="Renderer mode."),
) -> None:
"""Import real Sol bodies from published elevation data."""
from tooling.domains.atlas.planet import sol_import as impl
impl.main(_flags(
body=body,
download_only=download_only,
output_dir=output_dir,
heightmap_size=heightmap_size,
globe_size=globe_size,
render_mode=render_mode,
))
@app.command("sol-name-fixes")
@command
def sol_name_fixes(
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
) -> None:
"""Name the auto-detected features on Sol bodies that were left unnamed."""
from tooling.domains.atlas.planet import sol_name_fixes as impl
impl.main(_flags(dry_run=dry_run))
# --- audits ---------------------------------------------------------------
@app.command("audit")
@command
def audit(
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
) -> None:
"""Cohesion audit — does the atlas hang together across bodies."""
from tooling.domains.atlas.planet import atlas_cohesion_audit as impl
impl.main(_flags(system=system, body=body, db=db))
@app.command("quality")
@command
def quality(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
top_collisions: int = typer.Option(None, "--top-collisions", help="How many to list."),
) -> None:
"""Quality analysis — name collisions and distribution."""
from tooling.domains.atlas.planet import atlas_quality_analysis as impl
impl.main(_flags(db=db, system=system, body=body, top_collisions=top_collisions))
@@ -10,9 +10,9 @@ The frontmatter IS the body definition — the generator reads it directly.
Below the frontmatter is space for authored body content (narrative, notes).
Usage:
python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md
python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md --overrides sol_overrides.json
python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md --dry-run
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md --overrides sol_overrides.json
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md --dry-run
Only creates files that don't exist yet — never overwrites authored content.
Re-running is safe: existing body index.md files are skipped.
@@ -20,25 +20,28 @@ Re-running is safe: existing body index.md files are skipped.
import argparse
import json
import os
import sys
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
# Venv bootstrap
TOOLING_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
try:
import yaml
except ImportError:
# PyYAML is in pyproject.toml deps
print("error: PyYAML not installed — run `make setup-venv`", file=sys.stderr)
sys.exit(1)
raise ReachError("PyYAML not installed — run `make setup-venv`", fix="make install-reach — reach declares PyYAML in its own environment")
from tooling.domains.atlas.planet.body_definition_parser import parse_system
from body_definition_parser import parse_system
def _body_to_frontmatter(bd: dict) -> str:
@@ -147,14 +150,14 @@ def _body_prose(bd: dict, system_dir: Path) -> str:
return "\n".join(lines)
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Scaffold per-body index.md files from a system index.md")
parser.add_argument("system_index", help="Path to system index.md")
parser.add_argument("--overrides", help="Per-body overrides JSON")
parser.add_argument("--dry-run", action="store_true",
help="Print what would be created without writing")
args = parser.parse_args()
args = parser.parse_args(argv)
system_path = Path(args.system_index)
system_dir = system_path.parent
@@ -166,7 +169,7 @@ def main():
overrides = json.load(f)
body_defs = parse_system(str(system_path), overrides=overrides)
print(f"System: {system_path}{len(body_defs)} renderable bodies")
console.event(f"System: {system_path}{len(body_defs)} renderable bodies")
created = 0
skipped = 0
@@ -176,7 +179,7 @@ def main():
index_path = body_dir / "index.md"
if index_path.exists():
print(f" skip {body_id} — index.md exists")
console.event(f" skip {body_id} — index.md exists")
skipped += 1
continue
@@ -185,18 +188,18 @@ def main():
content = f"---\n{frontmatter}\n---\n\n{prose}"
if args.dry_run:
print(f" would create {index_path}")
print(f" {bd.get('planet_class', '?')} / {bd.get('body_type', '?')}")
console.event(f" would create {index_path}")
console.event(f" {bd.get('planet_class', '?')} / {bd.get('body_type', '?')}")
else:
body_dir.mkdir(parents=True, exist_ok=True)
with open(index_path, "w") as f:
f.write(content)
print(f" created {index_path}")
console.event(f" created {index_path}")
created += 1
action = "would create" if args.dry_run else "created"
print(f"\n {action} {created}, skipped {skipped}")
console.event(f" {action} {created}, skipped {skipped}")
if __name__ == "__main__":
@@ -6,25 +6,41 @@ Supports resume for large files and optional SHA-256 verification.
"""
import hashlib
import sys
import urllib.request
from pathlib import Path
from tooling.core import console
CACHE_DIR = Path(__file__).resolve().parent / ".cache"
# urlretrieve calls the hook once per 8 KB block. The old `\r` rewrite made that
# free on a terminal; as stream events it would be tens of thousands of lines in
# a job log, so progress is reported per 10% step (or per 50 MB when the server
# sends no length) instead.
_STEP_PCT = 10
_STEP_MB = 50
_last_step = -1
def _progress_hook(block_num, block_size, total_size):
"""Print download progress."""
"""Report download progress, one event per step."""
global _last_step
if block_num == 0:
_last_step = -1
downloaded = block_num * block_size
mb = downloaded / (1024 * 1024)
if total_size > 0:
pct = min(100.0, downloaded * 100.0 / total_size)
mb = downloaded / (1024 * 1024)
total_mb = total_size / (1024 * 1024)
sys.stdout.write(f"\r downloading: {mb:.1f}/{total_mb:.1f} MB ({pct:.0f}%)")
fraction = min(1.0, downloaded / total_size)
step = int(fraction * 100) // _STEP_PCT
if step != _last_step:
_last_step = step
total_mb = total_size / (1024 * 1024)
console.event(f"downloading: {mb:.1f}/{total_mb:.1f} MB", progress=fraction)
else:
mb = downloaded / (1024 * 1024)
sys.stdout.write(f"\r downloading: {mb:.1f} MB")
sys.stdout.flush()
step = int(mb) // _STEP_MB
if step != _last_step:
_last_step = step
console.event(f"downloading: {mb:.1f} MB")
def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
@@ -44,14 +60,14 @@ def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
if sha256:
actual = _sha256(local_path)
if actual != sha256:
print(f" WARNING: checksum mismatch for {filename}, re-downloading")
console.event(f"checksum mismatch for {filename}, re-downloading", level="warn")
local_path.unlink()
else:
return local_path
else:
return local_path
print(f" fetching {filename} from {url[:80]}...")
console.event(f" fetching {filename} from {url[:80]}...")
tmp_path = local_path.with_suffix(".tmp")
try:
@@ -62,7 +78,6 @@ def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
]
urllib.request.install_opener(opener)
urllib.request.urlretrieve(url, str(tmp_path), reporthook=_progress_hook)
print() # newline after progress
except Exception as e:
if tmp_path.exists():
tmp_path.unlink()
@@ -16,13 +16,14 @@ import zipfile
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array,
resample_to_grid, normalize_01, compute_sea_level,
compute_hillshade, assemble_terrain,
)
from tooling.core import console
# ─── Data source URLs ───────────────────────────────────────────────────────
@@ -113,7 +114,7 @@ def _match_river_name(feature_name: str) -> str:
def _load_etopo() -> np.ndarray:
"""Load ETOPO 2022 elevation data, return raw metres array."""
path = ensure_cached(ETOPO_URL, ETOPO_FILE)
print(f" loading ETOPO: {path}")
console.event(f" loading ETOPO: {path}")
try:
arr = load_tiff_as_array(str(path))
except Exception as e:
@@ -122,7 +123,7 @@ def _load_etopo() -> np.ndarray:
f"If PIL can't read this TIFF, install Pillow with TIFF support "
f"or convert to raw binary."
) from e
print(f" ETOPO shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" ETOPO shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
@@ -132,7 +133,7 @@ def _load_worldclim_temperature() -> np.ndarray:
Returns temperature in Kelvin at native resolution.
"""
zip_path = ensure_cached(WCLIM_TEMP_URL, WCLIM_TEMP_FILE)
print(f" loading WorldClim temperature: {zip_path}")
console.event(f" loading WorldClim temperature: {zip_path}")
# The zip contains monthly TIFFs (tavg_01.tif to tavg_12.tif).
# Compute annual mean from all 12 months.
@@ -177,7 +178,7 @@ def _load_worldclim_temperature() -> np.ndarray:
# Replace NaN (ocean/nodata) with a reasonable ocean temperature
temp_K = np.nan_to_num(temp_K, nan=288.0)
print(f" WorldClim temp shape: {temp_K.shape}, "
console.event(f" WorldClim temp shape: {temp_K.shape}, "
f"range: [{np.nanmin(temp_K):.0f}, {np.nanmax(temp_K):.0f}] K")
return temp_K
@@ -188,7 +189,7 @@ def _load_worldclim_precipitation() -> np.ndarray:
Returns precipitation in mm/year at native resolution.
"""
zip_path = ensure_cached(WCLIM_PREC_URL, WCLIM_PREC_FILE)
print(f" loading WorldClim precipitation: {zip_path}")
console.event(f" loading WorldClim precipitation: {zip_path}")
cache_dir = zip_path.parent
annual_sum = None
@@ -219,7 +220,7 @@ def _load_worldclim_precipitation() -> np.ndarray:
if annual_sum is None:
raise RuntimeError("No precipitation TIFFs found in WorldClim archive")
print(f" WorldClim precip shape: {annual_sum.shape}, "
console.event(f" WorldClim precip shape: {annual_sum.shape}, "
f"range: [{annual_sum.min():.0f}, {annual_sum.max():.0f}] mm/yr")
return annual_sum
@@ -230,7 +231,7 @@ def _load_rivers_geojson() -> list:
Returns list of (name, [(row, col), ...]) in grid coordinates.
"""
path = ensure_cached(RIVERS_URL, RIVERS_FILE)
print(f" loading rivers: {path}")
console.event(f" loading rivers: {path}")
with open(path) as f:
geojson = json.load(f)
@@ -280,7 +281,7 @@ def _load_rivers_geojson() -> list:
if name not in by_name or len(path) > len(by_name[name]):
by_name[name] = path
print(f" matched {len(by_name)} rivers: {', '.join(sorted(by_name.keys()))}")
console.event(f" matched {len(by_name)} rivers: {', '.join(sorted(by_name.keys()))}")
return [(name, path) for name, path in by_name.items()]
@@ -292,11 +293,9 @@ def build_terrain(body_def: dict) -> dict:
Returns the same dict format as planet_simulation.simulate().
"""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Earth: loading real-world data...")
console.event(" Earth: loading real-world data...")
# ── 1. Elevation ────────────────────────────────────────────────────
etopo_raw = _load_etopo()
@@ -312,7 +311,7 @@ def build_terrain(body_def: dict) -> dict:
sea_level = compute_sea_level(elevation, EARTH_OCEAN_FRACTION)
surface_water = elevation < sea_level
print(f" elevation: sea_level={sea_level:.4f}, "
console.event(f" elevation: sea_level={sea_level:.4f}, "
f"ocean={surface_water.sum()}/{GRID_H*GRID_W} cells")
# ── 2. Temperature ──────────────────────────────────────────────────
@@ -327,7 +326,7 @@ def build_terrain(body_def: dict) -> dict:
ocean_temp = 301.0 - lat_abs[:, np.newaxis] * 30.0 # ~28°C equator, ~-2°C poles
temperature_K = np.where(surface_water, ocean_temp, temperature_K)
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
precip_raw = _load_worldclim_precipitation()
@@ -340,14 +339,14 @@ def build_terrain(body_def: dict) -> dict:
# Ocean moisture = high (drives adjacent land humidity)
moisture = np.where(surface_water, 0.9, moisture)
print(f" moisture: [{moisture.min():.2f}, {moisture.max():.2f}]")
console.event(f" moisture: [{moisture.min():.2f}, {moisture.max():.2f}]")
# ── 4. Biome classification ─────────────────────────────────────────
# Use the existing Whittaker table with real temperature and moisture
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
n_biomes = len(np.unique(biome))
print(f" biomes: {n_biomes} classes present")
console.event(f" biomes: {n_biomes} classes present")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
@@ -369,7 +368,7 @@ def build_terrain(body_def: dict) -> dict:
n_orig = len(named_rivers)
n_kept = len(clipped)
print(f" rivers: {n_kept}/{n_orig} kept after water clipping")
console.event(f" rivers: {n_kept}/{n_orig} kept after water clipping")
named_rivers = clipped
rivers = [path for _, path in named_rivers]
@@ -9,6 +9,7 @@ The actual overrides are in sol_overrides.json and applied by the body
definition parser. This module exists for future enhancement (ring tuning,
storm placement, etc).
"""
from tooling.core import console
def validate_gas_giant_def(body_def: dict) -> bool:
@@ -19,7 +20,7 @@ def validate_gas_giant_def(body_def: dict) -> bool:
gg = body_def.get("gas_giant", {})
if not gg.get("band_palette"):
print(f" WARNING: {body_def['id']} missing gas_giant.band_palette")
console.event(f"{body_def['id']} missing gas_giant.band_palette", level="warn")
return False
return True
@@ -10,16 +10,16 @@ Each moon gets specific temperature and appearance tuning.
"""
import numpy as np
from pathlib import Path
from scipy.ndimage import gaussian_filter
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# ─── Per-moon configuration ─────────────────────────────────────────────────
@@ -67,12 +67,12 @@ def _load_mosaic_as_elevation(config: dict) -> np.ndarray:
"""Load a global mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(config["mosaic_url"], config["mosaic_file"])
print(f" loading {config['name']} mosaic: {path}")
console.event(f" loading {config['name']} mosaic: {path}")
albedo = load_image_as_elevation(str(path),
invert=config.get("invert_albedo", False))
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
except Exception as e:
print(f" WARNING: {config['name']} mosaic unavailable ({e}), synthetic")
console.event(f"{config['name']} mosaic unavailable ({e}), synthetic", level="warn")
albedo = _synthetic_ice_terrain(config["name"])
# Smooth albedo to create plausible topography
@@ -99,9 +99,7 @@ def _synthetic_ice_terrain(name: str) -> np.ndarray:
def build_terrain(body_def: dict) -> dict:
"""Build ice moon terrain dict from mosaic data."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
body_id = body_def["id"]
config = MOON_CONFIG.get(body_id)
@@ -109,7 +107,7 @@ def build_terrain(body_def: dict) -> dict:
if config is None:
raise ValueError(f"No ice moon config for {body_id}")
print(f" {config['name']}: loading data...")
console.event(f" {config['name']}: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_mosaic_as_elevation(config)
@@ -16,16 +16,16 @@ Properties:
"""
import numpy as np
from pathlib import Path
from scipy.ndimage import gaussian_filter
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# Io global mosaic (Galileo SSI + Voyager) — JPEG from USGS
# If direct download isn't available, fall back to procedural
@@ -40,10 +40,10 @@ def _load_io_mosaic() -> np.ndarray:
"""Load Io global mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(IO_MOSAIC_URL, IO_MOSAIC_FILE)
print(f" loading Io mosaic: {path}")
console.event(f" loading Io mosaic: {path}")
albedo = load_image_as_elevation(str(path), invert=False)
except Exception as e:
print(f" WARNING: Io mosaic unavailable ({e}), generating synthetic")
console.event(f"Io mosaic unavailable ({e}), generating synthetic", level="warn")
return _synthetic_io_terrain()
# Resample to grid
@@ -76,11 +76,9 @@ def _synthetic_io_terrain() -> np.ndarray:
def build_terrain(body_def: dict) -> dict:
"""Build Io terrain dict."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Io: loading data...")
console.event(" Io: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_io_mosaic()
@@ -14,16 +14,16 @@ Luna properties:
"""
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_raw_binary,
resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# LOLA GDR — available as PDS IMG files
# 4ppd (1440 × 720) — compact version
@@ -53,7 +53,7 @@ def _load_lola(use_16ppd: bool = False) -> np.ndarray:
url, filename, w, h = LOLA_4PPD_URL, LOLA_4PPD_FILE, LOLA_4PPD_W, LOLA_4PPD_H
path = ensure_cached(url, filename)
print(f" loading LOLA: {path} ({w}x{h})")
console.event(f" loading LOLA: {path} ({w}x{h})")
# LOLA GDR: little-endian int16 (LSB_INTEGER per PDS label)
# with a scaling factor of 0.5 metres.
@@ -69,23 +69,21 @@ def _load_lola(use_16ppd: bool = False) -> np.ndarray:
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
print(f" LOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" LOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
def build_terrain(body_def: dict) -> dict:
"""Build Luna terrain dict from LOLA data."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Luna: loading LOLA data...")
console.event(" Luna: loading LOLA data...")
# ── 1. Elevation ────────────────────────────────────────────────────
lola_raw = _load_lola(use_16ppd=False)
# LOLA cylindrical: col 0 = 0° longitude — shift to 180°W
from sol_data.shared import greenwich_to_dateline
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
lola_shifted = greenwich_to_dateline(lola_raw)
elevation_m = resample_to_grid(lola_shifted, GRID_H, GRID_W, order=1)
@@ -95,7 +93,7 @@ def build_terrain(body_def: dict) -> dict:
sea_level = 0.0
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
print(" elevation normalised")
console.event(" elevation normalised")
# ── 2. Temperature ──────────────────────────────────────────────────
temperature_K = temperature_grid_analytical(
@@ -107,7 +105,7 @@ def build_terrain(body_def: dict) -> dict:
# Clamp minimum
temperature_K = np.maximum(temperature_K, 40.0)
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
@@ -116,7 +114,7 @@ def build_terrain(body_def: dict) -> dict:
# body_type: "moon" + atmosphere: "none" → lunar palette (31/32/33)
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
print(f" biomes: {len(np.unique(biome))} classes")
console.event(f" biomes: {len(np.unique(biome))} classes")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
@@ -17,13 +17,14 @@ Mars properties:
import numpy as np
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_raw_binary, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# MOLA MEGDR — 4 pixels per degree (1440 × 720), big-endian int16
# Each pixel = metres relative to Mars areoid
@@ -62,7 +63,7 @@ def _load_mola(use_16ppd: bool = False) -> np.ndarray:
url, filename, w, h = MOLA_4PPD_URL, MOLA_4PPD_FILE, MOLA_4PPD_W, MOLA_4PPD_H
path = ensure_cached(url, filename)
print(f" loading MOLA: {path} ({w}x{h})")
console.event(f" loading MOLA: {path} ({w}x{h})")
# MOLA MEGDR: big-endian int16, metres, no header
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
@@ -71,19 +72,19 @@ def _load_mola(use_16ppd: bool = False) -> np.ndarray:
arr[arr > 30000] = 0.0
arr[arr < -30000] = 0.0
print(f" MOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" MOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
def build_terrain(body_def: dict) -> dict:
"""Build Mars terrain dict from MOLA data."""
print(" Mars: loading MOLA data...")
console.event(" Mars: loading MOLA data...")
# ── 1. Elevation ────────────────────────────────────────────────────
mola_raw = _load_mola(use_16ppd=False)
# MOLA is col 0 = 0° longitude — shift to col 0 = 180°W
from sol_data.shared import greenwich_to_dateline
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
mola_shifted = greenwich_to_dateline(mola_raw)
# Resample to grid
@@ -92,7 +93,7 @@ def build_terrain(body_def: dict) -> dict:
# Normalise to [0, 1]
elevation = normalize_01(elevation_m, MARS_MIN_ELEV_M, MARS_MAX_ELEV_M)
print(" elevation normalised")
console.event(" elevation normalised")
# ── 2. Temperature ──────────────────────────────────────────────────
# Analytical: equatorial ~210K, polar ~150K, elevation lapse
@@ -109,7 +110,7 @@ def build_terrain(body_def: dict) -> dict:
polar_rows = lat_abs > 0.75
temperature_K[polar_rows, :] = np.minimum(temperature_K[polar_rows, :], 155.0)
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
# Mars has almost no moisture — thin atmosphere
@@ -120,7 +121,7 @@ def build_terrain(body_def: dict) -> dict:
# ── 4. Terraformed water bodies ─────────────────────────────────────
# Lore: 800 years of partial terraforming. Water pools in the deepest
# basins (Hellas, Utopia, Isidis). ~2% of surface is now liquid water.
from sol_data.shared import compute_sea_level as _compute_sl
from tooling.domains.atlas.planet.sol_data.shared import compute_sea_level as _compute_sl
from scipy.ndimage import binary_dilation
TERRAFORM_OCEAN_FRAC = 0.02 # 2% water coverage
@@ -131,7 +132,7 @@ def build_terrain(body_def: dict) -> dict:
surface_water[polar_rows, :] = False
n_water = int(surface_water.sum())
print(f" terraformed water: {n_water} cells "
console.event(f" terraformed water: {n_water} cells "
f"(sea_level={sea_level:.4f})")
# ── 5. Biome classification ─────────────────────────────────────────
@@ -170,7 +171,7 @@ def build_terrain(body_def: dict) -> dict:
n_ferric = int(((biome >= 34) & (biome <= 36)).sum())
n_veg = int(((biome == 8) | (biome == 12)).sum())
n_ocean = int(((biome >= 0) & (biome <= 2)).sum())
print(f" biomes: {len(np.unique(biome))} classes "
console.event(f" biomes: {len(np.unique(biome))} classes "
f"(ferric={n_ferric}, ice={n_ice}, veg={n_veg}, water={n_ocean})")
# ── 5. Hillshade ────────────────────────────────────────────────────
@@ -14,16 +14,16 @@ Mercury properties:
"""
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array, load_raw_binary,
resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# MESSENGER DEM — try PDS binary first (compact), fall back to USGS GeoTIFF
MESSENGER_PDS_URL = "https://pds-geosciences.wustl.edu/messenger/mess-h-mdis_mla-6-dem-elevation-v1/messdmdem_1001/data/global_dem_16ppd.img"
@@ -46,46 +46,42 @@ def _load_messenger() -> np.ndarray:
# Try PDS binary first (compact ~33 MB)
try:
path = ensure_cached(MESSENGER_PDS_URL, MESSENGER_PDS_FILE)
print(f" loading MESSENGER PDS: {path}")
console.event(f" loading MESSENGER PDS: {path}")
arr = load_raw_binary(str(path), MESSENGER_PDS_W, MESSENGER_PDS_H,
dtype=">i2", offset=0)
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
print(f" MESSENGER range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" MESSENGER range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e:
print(f" PDS load failed ({e}), trying USGS GeoTIFF...")
console.event(f" PDS load failed ({e}), trying USGS GeoTIFF...")
# Fallback: USGS GeoTIFF (~506 MB)
try:
path = ensure_cached(MESSENGER_TIFF_URL, MESSENGER_TIFF_FILE)
print(f" loading MESSENGER GeoTIFF: {path}")
console.event(f" loading MESSENGER GeoTIFF: {path}")
arr = load_tiff_as_array(str(path))
arr[arr < -20000] = 0.0
print(f" MESSENGER shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" MESSENGER shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e2:
print(f" GeoTIFF also failed ({e2}), using procedural")
console.event(f" GeoTIFF also failed ({e2}), using procedural")
return None
def build_terrain(body_def: dict) -> dict:
"""Build Mercury terrain dict from MESSENGER data."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Mercury: loading MESSENGER data...")
console.event(" Mercury: loading MESSENGER data...")
# ── 1. Elevation ────────────────────────────────────────────────────
raw = _load_messenger()
if raw is None:
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import simulate
from tooling.domains.atlas.planet.planet_simulation import simulate
return simulate(body_def)
from sol_data.shared import greenwich_to_dateline
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
shifted = greenwich_to_dateline(raw)
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
elevation = normalize_01(elevation_m, MERCURY_MIN_ELEV_M, MERCURY_MAX_ELEV_M)
@@ -18,15 +18,15 @@ Properties:
"""
import numpy as np
from pathlib import Path
from scipy.ndimage import gaussian_filter
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
)
from tooling.core import console
# Cassini ISS global mosaic
TITAN_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/5e5ba96a58d3b38ee6e7b1e94b8c44e6_titan_iss_p19658_mosaic_global_4km.jpg"
@@ -40,11 +40,11 @@ def _load_titan_mosaic() -> np.ndarray:
"""Load Titan mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(TITAN_MOSAIC_URL, TITAN_MOSAIC_FILE)
print(f" loading Titan mosaic: {path}")
console.event(f" loading Titan mosaic: {path}")
albedo = load_image_as_elevation(str(path), invert=False)
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
except Exception as e:
print(f" WARNING: Titan mosaic unavailable ({e}), synthetic")
console.event(f"Titan mosaic unavailable ({e}), synthetic", level="warn")
albedo = _synthetic_titan_terrain()
# Dark regions = low (lakes/flat), bright = dunes/highlands
@@ -66,11 +66,9 @@ def _synthetic_titan_terrain() -> np.ndarray:
def build_terrain(body_def: dict) -> dict:
"""Build Titan terrain dict."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Titan: loading data...")
console.event(" Titan: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_titan_mosaic()
@@ -78,7 +76,7 @@ def build_terrain(body_def: dict) -> dict:
# Titan has methane lakes — set sea level to create them
# Lakes are concentrated at north polar regions
# Use a low sea level so that only the darkest (lowest) areas become liquid
from sol_data.shared import compute_sea_level
from tooling.domains.atlas.planet.sol_data.shared import compute_sea_level
sea_level = compute_sea_level(elevation, TITAN_METHANE_LAKE_FRACTION)
surface_water = elevation < sea_level
@@ -90,7 +88,7 @@ def build_terrain(body_def: dict) -> dict:
equatorial_mask = (lat_abs < 0.6)[:, np.newaxis] * np.ones(GRID_W, dtype=bool)
surface_water = surface_water & ~equatorial_mask
print(f" methane lakes: {surface_water.sum()} cells")
console.event(f" methane lakes: {surface_water.sum()} cells")
# ── 2. Temperature ──────────────────────────────────────────────────
# Titan has nearly uniform surface temp due to dense atmosphere + distance
@@ -118,7 +116,7 @@ def build_terrain(body_def: dict) -> dict:
# Override: methane lakes should be ocean classes, not ice
# (The biome function sets ocean depth bands for surface_water, which is
# what we want — methane lakes rendered like ocean)
print(f" biomes: {len(np.unique(biome))} classes")
console.event(f" biomes: {len(np.unique(biome))} classes")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
@@ -14,15 +14,15 @@ Venus properties:
"""
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array, load_raw_binary, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# Magellan topography — USGS GeoTIFF (reliable, PIL-loadable)
MAGELLAN_TIFF_URL = "https://planetarymaps.usgs.gov/mosaic/Venus_Magellan_Topography_Global_4641m_v02.tif"
@@ -42,56 +42,52 @@ def _load_magellan() -> np.ndarray:
# Try USGS GeoTIFF first (reliable, well-defined format)
try:
path = ensure_cached(MAGELLAN_TIFF_URL, MAGELLAN_TIFF_FILE)
print(f" loading Magellan GeoTIFF: {path}")
console.event(f" loading Magellan GeoTIFF: {path}")
arr = load_tiff_as_array(str(path))
# Handle nodata
arr[arr < -20000] = 0.0
arr[arr > 20000] = 0.0
print(f" Magellan shape: {arr.shape}, "
console.event(f" Magellan shape: {arr.shape}, "
f"range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e:
print(f" GeoTIFF failed ({e}), trying PDS binary...")
console.event(f" GeoTIFF failed ({e}), trying PDS binary...")
# PDS fallback — try common dimension/format combinations
try:
path = ensure_cached(MAGELLAN_PDS_URL, MAGELLAN_PDS_FILE)
print(f" loading Magellan PDS: {path}")
console.event(f" loading Magellan PDS: {path}")
for w, h in [(4096, 2048), (2048, 1024), (8192, 4096)]:
try:
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
print(f" Magellan PDS: {w}x{h}, range: [{arr.min():.0f}, {arr.max():.0f}]")
console.event(f" Magellan PDS: {w}x{h}, range: [{arr.min():.0f}, {arr.max():.0f}]")
return arr
except ValueError:
continue
except Exception as e3:
print(f" PDS also failed ({e3})")
console.event(f" PDS also failed ({e3})")
# All sources failed — fall through to procedural generation
print(" WARNING: all Magellan sources failed, using procedural")
console.event("all Magellan sources failed, using procedural", level="warn")
return None
def build_terrain(body_def: dict) -> dict:
"""Build Venus terrain dict from Magellan data."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Venus: loading Magellan data...")
console.event(" Venus: loading Magellan data...")
# ── 1. Elevation ────────────────────────────────────────────────────
raw = _load_magellan()
if raw is None:
# Fall back to procedural simulation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import simulate
from tooling.domains.atlas.planet.planet_simulation import simulate
return simulate(body_def)
from sol_data.shared import greenwich_to_dateline
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
shifted = greenwich_to_dateline(raw)
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
elevation = normalize_01(elevation_m, VENUS_MIN_ELEV_M, VENUS_MAX_ELEV_M)
@@ -7,40 +7,44 @@ markers.json, terrain.npz) by constructing terrain dicts from real
planetary science data instead of procedural simulation.
Usage:
python3 sol_import.py # All Sol bodies
python3 sol_import.py --body GJ0d # Earth only
python3 sol_import.py --body GJ0d --body GJ0e # Earth + Mars
python3 sol_import.py --download-only # Fetch data, skip rendering
python3 sol_import.py --heightmap-size 2048x1024 --globe-size 1024
reach atlas planet sol-import # All Sol bodies
reach atlas planet sol-import --body GJ0d # Earth only
reach atlas planet sol-import --body GJ0d --body GJ0e # Earth + Mars
reach atlas planet sol-import --download-only # Fetch data, skip rendering
reach atlas planet sol-import --heightmap-size 2048x1024 --globe-size 1024
Data is cached in tooling/planet-gen/sol_data/.cache/ after first download.
Data is cached in tooling/domains/atlas/planet/sol_data/.cache/ after first download.
"""
import argparse
import json
import os
import sys
import time
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
from tooling.core import config, console
from tooling.core.errors import ReachError
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
from planet_simulation import simulate
from render_heightmap import render_heightmap
from generate import _build_markers
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
from tooling.domains.atlas.planet.generate import _build_markers
# Per-body importers (lazy-loaded)
SOL_INDEX = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "index.md"
SOL_OVERRIDES = TOOLING_DIR / "sol_overrides.json"
SOL_OVERRIDES = PLANET_DIR / "sol_overrides.json"
SOL_BODIES_DIR = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
SOL_MARKERS_DIR = TOOLING_DIR / "sol_markers"
SOL_MARKERS_DIR = PLANET_DIR / "sol_markers"
# Bodies that use real-world data (keyed by body_id → importer module)
REAL_DATA_BODIES = {
@@ -67,7 +71,7 @@ SKIP_TYPES = {"asteroid_belt", "oort_cloud"}
def _load_importer(module_name: str):
"""Lazy-import a sol_data.* module."""
import importlib
return importlib.import_module(f"sol_data.{module_name}")
return importlib.import_module(f"tooling.domains.atlas.planet.sol_data.{module_name}")
def _apply_named_features(markers: dict, body_id: str) -> dict:
@@ -173,13 +177,13 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
# Skip non-renderable types
if body_type in SKIP_TYPES:
print(f"\n {body_id} ({name}) — skipped ({body_type})")
console.event(f" {body_id} ({name}) — skipped ({body_type})")
return
body_dir = output_dir / body_id
body_dir.mkdir(parents=True, exist_ok=True)
print(f"\n {body_id} ({name}) — {planet_class}")
console.event(f" {body_id} ({name}) — {planet_class}")
t0 = time.time()
@@ -190,33 +194,33 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
if is_gas:
# Gas giants: no terrain, renderer handles bands procedurally
terrain = {}
print(" terrain: gas giant (procedural bands)")
console.event(" terrain: gas giant (procedural bands)")
elif body_id in REAL_DATA_BODIES:
# Real-world data import
module_name = REAL_DATA_BODIES[body_id]
print(f" importing real data via sol_data.{module_name}...")
console.event(f" importing real data via sol_data.{module_name}...")
importer = _load_importer(module_name)
terrain = importer.build_terrain(body_def)
if download_only:
print(" download complete, skipping render")
console.event(" download complete, skipping render")
return
elif body_id in PROCEDURAL_BODIES:
# Fall through to standard procedural simulation
print(" procedural simulation (irregular body)...")
console.event(" procedural simulation (irregular body)...")
terrain = simulate(body_def)
else:
print(f" WARNING: no importer for {body_id}, using procedural")
console.event(f"no importer for {body_id}, using procedural", level="warn")
terrain = simulate(body_def)
t_terrain = time.time()
if terrain:
print(f" terrain: {t_terrain - t0:.1f}s "
console.event(f" terrain: {t_terrain - t0:.1f}s "
f"sea={terrain['sea_level']:.3f} "
f"land={int((~terrain['surface_water']).sum())} "
f"rivers={len(terrain['rivers'])}")
else:
print(f" terrain: gas giant ({t_terrain - t0:.1f}s)")
console.event(f" terrain: gas giant ({t_terrain - t0:.1f}s)")
# ── 2. Render heightmap ─────────────────────────────────────────────
t_hmap = t_terrain
@@ -226,17 +230,17 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
render_mode=render_mode, chrome=False)
hmap_img.save(str(body_dir / "heightmap.png"))
t_hmap = time.time()
print(f" heightmap: {t_hmap - t_terrain:.1f}s {hmap_w}x{hmap_h}")
console.event(f" heightmap: {t_hmap - t_terrain:.1f}s {hmap_w}x{hmap_h}")
# ── 3. Render globe ─────────────────────────────────────────────────
try:
from planet_renderer import render_globe
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(body_def, terrain, size=globe_size)
globe_img.save(str(body_dir / "globe.png"))
t_globe = time.time()
print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}x{globe_size}")
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}x{globe_size}")
except Exception as e:
print(f" globe: FAILED — {e}")
console.event(f" globe: FAILED — {e}")
t_globe = time.time()
# ── 4. Write data files ─────────────────────────────────────────────
@@ -260,7 +264,7 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
_write_index_md(body_def, body_dir)
elapsed = time.time() - t0
print(f" total: {elapsed:.1f}s -> {body_dir}/")
console.event(f" total: {elapsed:.1f}s -> {body_dir}/")
def _write_index_md(body_def: dict, body_dir: Path):
@@ -298,7 +302,7 @@ def _write_index_md(body_def: dict, body_dir: Path):
f.write(md)
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Sol system (GJ-0) real-world terrain importer")
@@ -315,21 +319,19 @@ def main():
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
default="cartographic")
args = parser.parse_args()
args = parser.parse_args(argv)
# Parse heightmap size
try:
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
except ValueError:
print(f"error: invalid heightmap size '{args.heightmap_size}'",
file=sys.stderr)
sys.exit(1)
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
output_dir = Path(args.output_dir) if args.output_dir else SOL_BODIES_DIR
# Parse body definitions from GJ-0 index.md
from body_definition_parser import parse_system
from tooling.domains.atlas.planet.body_definition_parser import parse_system
overrides = {}
if SOL_OVERRIDES.exists():
@@ -337,15 +339,14 @@ def main():
overrides = json.load(f)
body_defs = parse_system(str(SOL_INDEX), overrides=overrides)
print(f"Sol system: {len(body_defs)} bodies parsed")
console.event(f"Sol system: {len(body_defs)} bodies parsed")
# Filter to requested bodies
if args.body:
requested = set(args.body)
body_defs = [bd for bd in body_defs if bd["id"] in requested]
if not body_defs:
print(f"error: no matching bodies for {args.body}", file=sys.stderr)
sys.exit(1)
raise ReachError(f"no matching bodies for {args.body}", fix="pass a Sol body id from wiki/star-systems/GJ-0/bodies/ (e.g. GJ0c), or omit --body")
# Generate
t_total = time.time()
@@ -356,14 +357,14 @@ def main():
args.render_mode, output_dir,
download_only=args.download_only)
except Exception as e:
print(f"\n FAILED: {bd['id']}{e}")
console.event(f" FAILED: {bd['id']}{e}")
failed.append(bd["id"])
elapsed = time.time() - t_total
n_ok = len(body_defs) - len(failed)
print(f"\n Done: {n_ok}/{len(body_defs)} bodies in {elapsed:.1f}s")
console.event(f" Done: {n_ok}/{len(body_defs)} bodies in {elapsed:.1f}s")
if failed:
print(f" Failed: {', '.join(failed)}")
console.event(f" Failed: {', '.join(failed)}")
if __name__ == "__main__":
@@ -6,14 +6,16 @@ Targets features with null names: Earth oceans/rivers, Luna/Mars/Europa mountain
All names are real-world geographic names for Sol bodies.
Usage:
python3 sol_name_fixes.py # apply all fixes
python3 sol_name_fixes.py --dry-run # print planned changes without writing
reach atlas planet sol-name-fixes # apply all fixes
reach atlas planet sol-name-fixes --dry-run # print planned changes without writing
"""
import argparse
import json
from pathlib import Path
REPO_ROOT = (Path(__file__).resolve().parent / ".." / "..").resolve()
from tooling.core import config, console
REPO_ROOT = config.repo_root()
WIKI = REPO_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
# Keys are feature IDs; values are the names to assign.
@@ -102,7 +104,7 @@ def apply_sol_fixes(dry_run: bool = False) -> None:
for body_id, body_fixes in FIXES.items():
path = WIKI / body_id / "markers.json"
if not path.exists():
print(f" SKIP {body_id}: markers.json not found")
console.event(f" SKIP {body_id}: markers.json not found")
continue
with open(path) as f:
@@ -117,24 +119,23 @@ def apply_sol_fixes(dry_run: bool = False) -> None:
old = feature.get("name")
new = id_map[fid]
if old != new:
print(f" [{body_id}/{section}] {fid}: {old!r}{new!r}")
console.event(f" [{body_id}/{section}] {fid}: {old!r}{new!r}")
if not dry_run:
feature["name"] = new
changed = True
if changed:
if dry_run:
print(f" (dry-run) Would write: {path}")
console.event(f" (dry-run) Would write: {path}")
else:
with open(path, "w") as f:
json.dump(markers, f, indent=2)
print(f" Written: {path}")
console.event(f" Written: {path}")
else:
print(f" No changes for {body_id}")
print()
console.event(f" No changes for {body_id}")
if __name__ == "__main__":
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Name previously-unnamed Sol body auto-detected features."
)
@@ -143,5 +144,9 @@ if __name__ == "__main__":
action="store_true",
help="print planned changes without writing any files",
)
args = parser.parse_args()
args = parser.parse_args(argv)
apply_sol_fixes(dry_run=args.dry_run)
if __name__ == "__main__":
main()
+12
View File
@@ -154,3 +154,15 @@ def flatness_report(
fix="re-run the capture, or check .cache/screenshots/ for the ladder",
exit_code=code,
)
# --- the rungs below the body surface -------------------------------------
#
# `planet` is a nested GROUP, not a domain of its own: the ladder is one
# subject (D-243), and a body is a rung of it rather than a peer. It is added
# at import time but its own module tree loads only when a `planet` verb runs
# — same lazy contract as the domains themselves.
from tooling.domains.atlas.planet.router import app as _planet_app # noqa: E402
app.add_typer(_planet_app, name="planet")
+2 -2
View File
@@ -15,8 +15,8 @@ REPO = "jpmschweitzer/settled-reach"
# set is read from the registry rather than restated, so this list cannot drift
# from tooling/generator_sources.py (T-1067).
EXTRA_WATCHED = (
"tooling/planet-gen/import_heightmaps.py",
"tooling/planet-gen/import_province_boundaries.py",
"tooling/domains/atlas/planet/import_heightmaps.py",
"tooling/domains/atlas/planet/import_province_boundaries.py",
"server/data/systems-schema.sql",
"wiki/star-systems/",
"wiki/economics/",
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# Batch planet generation — process all systems unattended.
# Usage: tooling/planet-gen/batch [--scaffold-only] [--generate-only] [--system GJ-144]
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/batch.py" "$@"
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# Planet generator CLI wrapper.
# Usage: tooling/planet-gen/generate body_def.json --output-dir ./output
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/generate.py" "$@"
+14 -6
View File
@@ -231,18 +231,26 @@ from tooling.core.command import MARKER
from tooling.main import DOMAINS, _load_domain
report = []
for name in sorted(DOMAINS):
group = _load_domain(name)
ctx = None
for verb in group.list_commands(ctx):
cmd = group.get_command(ctx, verb)
def walk(name, group, prefix):
# A nested group (`atlas planet`) is not a verb: its callback only keeps it
# a group. Recurse into it instead, so the verbs underneath are held to the
# contract too -- a one-level walk reported the group and skipped all ten.
for verb in group.list_commands(None):
cmd = group.get_command(None, verb)
if hasattr(cmd, "list_commands"):
walk(name, cmd, prefix + verb + " ")
continue
callback = getattr(cmd, "callback", None)
report.append({
"domain": name,
"verb": verb,
"verb": prefix + verb,
"decorated": bool(getattr(callback, MARKER, False)),
"help": (cmd.help or cmd.short_help or "").strip(),
})
for name in sorted(DOMAINS):
walk(name, _load_domain(name), "")
print(json.dumps(report))
"""
result = subprocess.run(
@@ -6,7 +6,7 @@ The 1024×512 elevation bump (D-202 amended) claims to be deterministic — the
means a full regeneration. This asserts that simulating the same body twice
yields a bit-identical elevation array.
Run: uv run python tooling/planet-gen/test_sim_determinism.py
Run: .venv/bin/python tooling/test_planet_determinism.py
Exit: 0 = deterministic, 1 = drift detected, 2 = could not find a test body.
"""
import hashlib
@@ -14,13 +14,13 @@ import sqlite3
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "tooling" / "planet-gen"))
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
import numpy as np # noqa: E402
from body_definition_parser import parse_system # noqa: E402
from planet_simulation import GRID_H, GRID_W, simulate # noqa: E402
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import GRID_H, GRID_W, simulate # noqa: E402
def _elev_hash(terrain) -> str:
@@ -11,16 +11,16 @@ original authored constants were tuned at. See the module docstring on
Pure arithmetic (no numpy/scipy dependency) stdlib `unittest` only. Run
directly or via `make test-tooling`:
python3 tooling/planet-gen/test_oasis_ring_scaling.py
.venv/bin/python tooling/test_planet_oasis_rings.py
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import GRID_W, oasis_ring_iterations # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import GRID_W, oasis_ring_iterations # noqa: E402
class OasisRingScalingTests(unittest.TestCase):
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""`atlas planet` declares its options twice — this stops them drifting (T-1288).
The router restates ten argument surfaces that already exist in the modules'
own argparse parsers. That duplication buys real `--help` for an agent, which
a passthrough could not, but it creates the obvious failure: the router grows
an option the module has never heard of, and the mismatch only shows up when
someone runs the command with that flag.
So every option the router declares is handed to the module's own parser here.
A parser rejects an unknown option with SystemExit(2), which is exactly the
signal wanted — no output comparison, no fixtures, no running the generators.
Nothing here executes a generator. Each parser is invoked directly, so a full
pass costs milliseconds and never touches the atlas DB.
Run: python3 tooling/test_planet_router.py
"""
from __future__ import annotations
import argparse
import contextlib
import io
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
import typer # noqa: E402
from tooling.domains.atlas.planet import router as planet_router # noqa: E402
# verb -> (module attribute, positional arguments the parser requires)
IMPLEMENTATIONS = {
"generate": ("generate", ["body.json"]),
"batch": ("batch", []),
"scaffold": ("scaffold_bodies", ["index.json"]),
"import-heightmaps": ("import_heightmaps", []),
"import-provinces": ("import_province_boundaries", []),
"terrain-reference": ("populate_terrain_reference", []),
"sol-import": ("sol_import", []),
"sol-name-fixes": ("sol_name_fixes", []),
"audit": ("atlas_cohesion_audit", []),
"quality": ("atlas_quality_analysis", []),
}
# A representative value per click param type, so the parser sees a well-formed
# pair. Keyed on the type's `name` because these are ParamType INSTANCES, not
# classes — keying on the class matches nothing and every int option then gets
# the string "x" and reads as a parser rejection.
SAMPLE = {"int": "4", "str": "x", "path": "x", "filename": "x", "float": "1.0"}
# Options whose module-side parser restricts the value. The router cannot send
# a placeholder to these, so the test sends a real one.
CONSTRAINED = {
("generate", "--render-mode"): "cartographic",
("sol-import", "--render-mode"): "cartographic",
}
def _router_options(verb: str) -> list[tuple[str, type]]:
"""The long options the router declares for a verb, with their types."""
command = typer.main.get_command(planet_router.app).commands[verb] # type: ignore[attr-defined]
found: list[tuple[str, type]] = []
for param in command.params:
for opt in getattr(param, "opts", []):
if opt.startswith("--") and opt != "--help":
found.append((opt, param.type))
return found
class _Parsed(BaseException):
"""Raised the instant a parser accepts, to stop before any work happens."""
def _parser_accepts(module, argv: list[str]) -> tuple[bool, str]:
"""Feed argv to the module's own parser, and stop the moment it accepts.
`main()` builds its parser and then does the work, so simply calling it
would generate planets. Patching `parse_args` lets the module construct its
real parser — the thing under test — and aborts on the line after it
succeeds. A rejection still raises SystemExit(2) from inside argparse.
Found the hard way: the first version of this test called `main()` and let
it run, and spent two minutes generating bodies for GJ_1005A before it was
stopped.
"""
real = argparse.ArgumentParser.parse_args
def stop_after_parsing(self, args=None, namespace=None):
real(self, args, namespace)
raise _Parsed
buffer = io.StringIO()
argparse.ArgumentParser.parse_args = stop_after_parsing
try:
with contextlib.redirect_stderr(buffer), contextlib.redirect_stdout(buffer):
module.main(argv)
except _Parsed:
return True, ""
except SystemExit as exc:
if exc.code == 2: # argparse's "bad arguments"
return False, buffer.getvalue().strip()
return True, ""
except BaseException:
# Failed before reaching parse_args — an import guard, a missing DB.
# Not this test's business either way.
return True, ""
finally:
argparse.ArgumentParser.parse_args = real
return True, ""
def test_every_option_is_known_to_its_parser(failures: list[str]) -> None:
import importlib
for verb, (module_name, positionals) in IMPLEMENTATIONS.items():
module = importlib.import_module(f"tooling.domains.atlas.planet.{module_name}")
for opt, param_type in _router_options(verb):
argv = list(positionals)
argv.append(opt)
type_name = getattr(param_type, "name", "text")
if type_name != "boolean":
argv.append(CONSTRAINED.get((verb, opt), SAMPLE.get(type_name, "x")))
ok, err = _parser_accepts(module, argv)
if not ok:
failures.append(
f"`reach atlas planet {verb} {opt}` — {module_name}.py's parser "
f"does not accept it: {err.splitlines()[-1] if err else 'rejected'}"
)
def test_every_verb_has_an_implementation(failures: list[str]) -> None:
"""A verb missing from IMPLEMENTATIONS would be silently unchecked."""
declared = set(typer.main.get_command(planet_router.app).commands) # type: ignore[attr-defined]
covered = set(IMPLEMENTATIONS)
for verb in sorted(declared - covered):
failures.append(
f"`reach atlas planet {verb}` is not in IMPLEMENTATIONS — add it, or "
"its options are never checked against a parser"
)
for verb in sorted(covered - declared):
failures.append(f"IMPLEMENTATIONS names '{verb}', which the router does not declare")
def test_flags_builder_drops_defaults(failures: list[str]) -> None:
"""None and False must not reach argv — the module owns its defaults."""
built = planet_router._flags(system=None, force=False, body="GJ380c", limit=7, dry_run=True)
if "--system" in built or "--force" in built:
failures.append(f"_flags passed an unset option through: {built}")
if built != ["--body", "GJ380c", "--limit", "7", "--dry-run"]:
failures.append(f"_flags built unexpected argv: {built}")
# sol-import's --body is action="append"; a list must repeat the flag, or
# `--body GJ0d --body GJ0e` silently keeps only one of them.
repeated = planet_router._flags(body=["GJ0d", "GJ0e"])
if repeated != ["--body", "GJ0d", "--body", "GJ0e"]:
failures.append(f"_flags did not repeat a list option: {repeated}")
def main() -> int:
failures: list[str] = []
test_every_verb_has_an_implementation(failures)
test_flags_builder_drops_defaults(failures)
test_every_option_is_known_to_its_parser(failures)
if failures:
print("test_planet_router: FAIL", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
total = sum(len(_router_options(v)) for v in IMPLEMENTATIONS)
print(
f"test_planet_router: OK — {len(IMPLEMENTATIONS)} verbs, {total} options, "
"every one accepted by the module's own parser"
)
return 0
if __name__ == "__main__":
sys.exit(main())