From 668772075cbddc62b2bb7cff12ae1faa356309db Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 23 Sep 2026 16:08:02 +0200 Subject: [PATCH] =?UTF-8?q?refactor(tooling):=20T-1288=20=E2=80=94=20plane?= =?UTF-8?q?t-gen=20becomes=20reach=20atlas=20planet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/rules/asset-pipeline.md | 4 +- .claude/skills/wiki/SKILL.md | 8 +- .config/hooks/pre-push | 2 +- .pql/changelog/ticket_history/2026-09.sql | 56 ++++ .pql/changelog/ticket_idmap/2026-09.sql | 1 + .pql/changelog/tickets/2026-09.sql | 59 ++++ Makefile | 11 +- docs/DEVOPS.md | 2 +- docs/atlas/hand-refine-log.md | 6 +- .../sprints/refine-log-T-849.md | 0 tooling/DOMAINS.md | 2 +- tooling/domains/atlas/planet/__init__.py | 22 ++ .../atlas/planet}/atlas_cohesion_audit.py | 78 +++--- .../atlas/planet}/atlas_common.py | 9 +- .../atlas/planet}/atlas_quality_analysis.py | 60 ++-- .../atlas/planet}/batch.py | 119 ++++---- .../atlas/planet}/biome_config.py | 2 +- .../atlas/planet}/biomes.toml | 0 .../atlas/planet}/body_definition_parser.py | 9 +- .../atlas/planet}/earth_blocklist.txt | 0 .../atlas/planet}/generate.py | 62 ++--- .../atlas/planet}/import_heightmaps.py | 46 +-- .../planet}/import_province_boundaries.py | 65 +++-- .../atlas/planet}/planet_renderer.py | 9 +- .../atlas/planet}/planet_simulation.py | 19 +- .../planet}/populate_terrain_reference.py | 52 ++-- .../atlas/planet}/render_heightmap.py | 23 +- tooling/domains/atlas/planet/router.py | 261 ++++++++++++++++++ .../atlas/planet}/scaffold_bodies.py | 45 +-- .../atlas/planet}/sol_data/__init__.py | 0 .../atlas/planet}/sol_data/download.py | 39 ++- .../atlas/planet}/sol_data/earth.py | 37 ++- .../atlas/planet}/sol_data/gas_giants.py | 3 +- .../atlas/planet}/sol_data/ice_moons.py | 16 +- .../atlas/planet}/sol_data/io_moon.py | 16 +- .../atlas/planet}/sol_data/luna.py | 24 +- .../atlas/planet}/sol_data/mars.py | 23 +- .../atlas/planet}/sol_data/mercury.py | 30 +- .../atlas/planet}/sol_data/shared.py | 0 .../atlas/planet}/sol_data/titan.py | 22 +- .../atlas/planet}/sol_data/venus.py | 32 +-- .../atlas/planet}/sol_import.py | 91 +++--- .../planet}/sol_markers/earth_features.json | 0 .../planet}/sol_markers/luna_features.json | 0 .../planet}/sol_markers/mars_features.json | 0 .../planet}/sol_markers/outer_features.json | 0 .../atlas/planet}/sol_name_fixes.py | 29 +- .../atlas/planet}/sol_overrides.json | 0 tooling/domains/atlas/router.py | 12 + tooling/domains/pr/service.py | 4 +- tooling/planet-gen/batch | 5 - tooling/planet-gen/generate | 5 - tooling/test_conformance.py | 20 +- ...erminism.py => test_planet_determinism.py} | 10 +- ..._scaling.py => test_planet_oasis_rings.py} | 6 +- tooling/test_planet_router.py | 183 ++++++++++++ 56 files changed, 1130 insertions(+), 509 deletions(-) rename tooling/planet-gen/refine_log_849.md => docs/sprints/refine-log-T-849.md (100%) create mode 100644 tooling/domains/atlas/planet/__init__.py rename tooling/{planet-gen => domains/atlas/planet}/atlas_cohesion_audit.py (86%) rename tooling/{planet-gen => domains/atlas/planet}/atlas_common.py (96%) rename tooling/{planet-gen => domains/atlas/planet}/atlas_quality_analysis.py (83%) rename tooling/{planet-gen => domains/atlas/planet}/batch.py (81%) rename tooling/{planet-gen => domains/atlas/planet}/biome_config.py (98%) rename tooling/{planet-gen => domains/atlas/planet}/biomes.toml (100%) rename tooling/{planet-gen => domains/atlas/planet}/body_definition_parser.py (98%) rename tooling/{planet-gen => domains/atlas/planet}/earth_blocklist.txt (100%) rename tooling/{planet-gen => domains/atlas/planet}/generate.py (83%) rename tooling/{planet-gen => domains/atlas/planet}/import_heightmaps.py (76%) rename tooling/{planet-gen => domains/atlas/planet}/import_province_boundaries.py (88%) rename tooling/{planet-gen => domains/atlas/planet}/planet_renderer.py (99%) rename tooling/{planet-gen => domains/atlas/planet}/planet_simulation.py (98%) rename tooling/{planet-gen => domains/atlas/planet}/populate_terrain_reference.py (69%) rename tooling/{planet-gen => domains/atlas/planet}/render_heightmap.py (95%) create mode 100644 tooling/domains/atlas/planet/router.py rename tooling/{planet-gen => domains/atlas/planet}/scaffold_bodies.py (80%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/__init__.py (100%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/download.py (66%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/earth.py (92%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/gas_giants.py (87%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/ice_moons.py (92%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/io_moon.py (91%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/luna.py (86%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/mars.py (90%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/mercury.py (81%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/shared.py (100%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/titan.py (91%) rename tooling/{planet-gen => domains/atlas/planet}/sol_data/venus.py (82%) rename tooling/{planet-gen => domains/atlas/planet}/sol_import.py (78%) rename tooling/{planet-gen => domains/atlas/planet}/sol_markers/earth_features.json (100%) rename tooling/{planet-gen => domains/atlas/planet}/sol_markers/luna_features.json (100%) rename tooling/{planet-gen => domains/atlas/planet}/sol_markers/mars_features.json (100%) rename tooling/{planet-gen => domains/atlas/planet}/sol_markers/outer_features.json (100%) rename tooling/{planet-gen => domains/atlas/planet}/sol_name_fixes.py (89%) rename tooling/{planet-gen => domains/atlas/planet}/sol_overrides.json (100%) delete mode 100755 tooling/planet-gen/batch delete mode 100755 tooling/planet-gen/generate rename tooling/{planet-gen/test_sim_determinism.py => test_planet_determinism.py} (86%) rename tooling/{planet-gen/test_oasis_ring_scaling.py => test_planet_oasis_rings.py} (90%) create mode 100644 tooling/test_planet_router.py diff --git a/.claude/rules/asset-pipeline.md b/.claude/rules/asset-pipeline.md index 6745d2c4a..7909e6c32 100644 --- a/.claude/rules/asset-pipeline.md +++ b/.claude/rules/asset-pipeline.md @@ -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. --- diff --git a/.claude/skills/wiki/SKILL.md b/.claude/skills/wiki/SKILL.md index 541d5ec6b..6cf86cee2 100644 --- a/.claude/skills/wiki/SKILL.md +++ b/.claude/skills/wiki/SKILL.md @@ -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`, diff --git a/.config/hooks/pre-push b/.config/hooks/pre-push index 64c34a2b5..15355b557 100755 --- a/.config/hooks/pre-push +++ b/.config/hooks/pre-push @@ -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 diff --git a/.pql/changelog/ticket_history/2026-09.sql b/.pql/changelog/ticket_history/2026-09.sql index ed43495e7..174a766ff 100644 --- a/.pql/changelog/ticket_history/2026-09.sql +++ b/.pql/changelog/ticket_history/2026-09.sql @@ -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 `, 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 `, 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; diff --git a/.pql/changelog/ticket_idmap/2026-09.sql b/.pql/changelog/ticket_idmap/2026-09.sql index 9f7d2558d..5b1e49a24 100644 --- a/.pql/changelog/ticket_idmap/2026-09.sql +++ b/.pql/changelog/ticket_idmap/2026-09.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-09.sql b/.pql/changelog/tickets/2026-09.sql index bb5c40f9e..b7285299e 100644 --- a/.pql/changelog/tickets/2026-09.sql +++ b/.pql/changelog/tickets/2026-09.sql @@ -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 `, 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 `, 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; diff --git a/Makefile b/Makefile index c2eb12eea..15d9fb46a 100644 --- a/Makefile +++ b/Makefile @@ -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 || \ diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index ed8ec8876..06a498cf9 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -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: diff --git a/docs/atlas/hand-refine-log.md b/docs/atlas/hand-refine-log.md index 62cb023bf..84f87e2c7 100644 --- a/docs/atlas/hand-refine-log.md +++ b/docs/atlas/hand-refine-log.md @@ -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.* --- diff --git a/tooling/planet-gen/refine_log_849.md b/docs/sprints/refine-log-T-849.md similarity index 100% rename from tooling/planet-gen/refine_log_849.md rename to docs/sprints/refine-log-T-849.md diff --git a/tooling/DOMAINS.md b/tooling/DOMAINS.md index 869de7a13..76bc23dee 100644 --- a/tooling/DOMAINS.md +++ b/tooling/DOMAINS.md @@ -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` | diff --git a/tooling/domains/atlas/planet/__init__.py b/tooling/domains/atlas/planet/__init__.py new file mode 100644 index 000000000..87097bd0c --- /dev/null +++ b/tooling/domains/atlas/planet/__init__.py @@ -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/`. +""" diff --git a/tooling/planet-gen/atlas_cohesion_audit.py b/tooling/domains/atlas/planet/atlas_cohesion_audit.py similarity index 86% rename from tooling/planet-gen/atlas_cohesion_audit.py rename to tooling/domains/atlas/planet/atlas_cohesion_audit.py index 5d270fe7c..1818d7aa5 100644 --- a/tooling/planet-gen/atlas_cohesion_audit.py +++ b/tooling/domains/atlas/planet/atlas_cohesion_audit.py @@ -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__": diff --git a/tooling/planet-gen/atlas_common.py b/tooling/domains/atlas/planet/atlas_common.py similarity index 96% rename from tooling/planet-gen/atlas_common.py rename to tooling/domains/atlas/planet/atlas_common.py index 10dee9241..7c3453037 100644 --- a/tooling/planet-gen/atlas_common.py +++ b/tooling/domains/atlas/planet/atlas_common.py @@ -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" diff --git a/tooling/planet-gen/atlas_quality_analysis.py b/tooling/domains/atlas/planet/atlas_quality_analysis.py similarity index 83% rename from tooling/planet-gen/atlas_quality_analysis.py rename to tooling/domains/atlas/planet/atlas_quality_analysis.py index aa93aa00a..28859bcaa 100644 --- a/tooling/planet-gen/atlas_quality_analysis.py +++ b/tooling/domains/atlas/planet/atlas_quality_analysis.py @@ -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) diff --git a/tooling/planet-gen/batch.py b/tooling/domains/atlas/planet/batch.py similarity index 81% rename from tooling/planet-gen/batch.py rename to tooling/domains/atlas/planet/batch.py index 6d344efec..61a8605c1 100644 --- a/tooling/planet-gen/batch.py +++ b/tooling/domains/atlas/planet/batch.py @@ -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__": diff --git a/tooling/planet-gen/biome_config.py b/tooling/domains/atlas/planet/biome_config.py similarity index 98% rename from tooling/planet-gen/biome_config.py rename to tooling/domains/atlas/planet/biome_config.py index 646ff85c9..6aedb8204 100644 --- a/tooling/planet-gen/biome_config.py +++ b/tooling/domains/atlas/planet/biome_config.py @@ -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, diff --git a/tooling/planet-gen/biomes.toml b/tooling/domains/atlas/planet/biomes.toml similarity index 100% rename from tooling/planet-gen/biomes.toml rename to tooling/domains/atlas/planet/biomes.toml diff --git a/tooling/planet-gen/body_definition_parser.py b/tooling/domains/atlas/planet/body_definition_parser.py similarity index 98% rename from tooling/planet-gen/body_definition_parser.py rename to tooling/domains/atlas/planet/body_definition_parser.py index bdee957f1..6f98b9d04 100644 --- a/tooling/planet-gen/body_definition_parser.py +++ b/tooling/domains/atlas/planet/body_definition_parser.py @@ -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)) diff --git a/tooling/planet-gen/earth_blocklist.txt b/tooling/domains/atlas/planet/earth_blocklist.txt similarity index 100% rename from tooling/planet-gen/earth_blocklist.txt rename to tooling/domains/atlas/planet/earth_blocklist.txt diff --git a/tooling/planet-gen/generate.py b/tooling/domains/atlas/planet/generate.py similarity index 83% rename from tooling/planet-gen/generate.py rename to tooling/domains/atlas/planet/generate.py index e3d850a10..4e13e8d87 100644 --- a/tooling/planet-gen/generate.py +++ b/tooling/domains/atlas/planet/generate.py @@ -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 ") 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 ") 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__": diff --git a/tooling/planet-gen/import_heightmaps.py b/tooling/domains/atlas/planet/import_heightmaps.py similarity index 76% rename from tooling/planet-gen/import_heightmaps.py rename to tooling/domains/atlas/planet/import_heightmaps.py index b7658387c..7e7a886c2 100644 --- a/tooling/planet-gen/import_heightmaps.py +++ b/tooling/domains/atlas/planet/import_heightmaps.py @@ -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 diff --git a/tooling/planet-gen/import_province_boundaries.py b/tooling/domains/atlas/planet/import_province_boundaries.py similarity index 88% rename from tooling/planet-gen/import_province_boundaries.py rename to tooling/domains/atlas/planet/import_province_boundaries.py index 47cb39c6f..2664ba7fe 100644 --- a/tooling/planet-gen/import_province_boundaries.py +++ b/tooling/domains/atlas/planet/import_province_boundaries.py @@ -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 --verbose") if __name__ == "__main__": diff --git a/tooling/planet-gen/planet_renderer.py b/tooling/domains/atlas/planet/planet_renderer.py similarity index 99% rename from tooling/planet-gen/planet_renderer.py rename to tooling/domains/atlas/planet/planet_renderer.py index 063fb0626..232022476 100644 --- a/tooling/planet-gen/planet_renderer.py +++ b/tooling/domains/atlas/planet/planet_renderer.py @@ -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.") diff --git a/tooling/planet-gen/planet_simulation.py b/tooling/domains/atlas/planet/planet_simulation.py similarity index 98% rename from tooling/planet-gen/planet_simulation.py rename to tooling/domains/atlas/planet/planet_simulation.py index 7c0c6d117..c906802f5 100644 --- a/tooling/planet-gen/planet_simulation.py +++ b/tooling/domains/atlas/planet/planet_simulation.py @@ -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}/") diff --git a/tooling/planet-gen/populate_terrain_reference.py b/tooling/domains/atlas/planet/populate_terrain_reference.py similarity index 69% rename from tooling/planet-gen/populate_terrain_reference.py rename to tooling/domains/atlas/planet/populate_terrain_reference.py index d4bb47833..0200c9e96 100644 --- a/tooling/planet-gen/populate_terrain_reference.py +++ b/tooling/domains/atlas/planet/populate_terrain_reference.py @@ -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__": diff --git a/tooling/planet-gen/render_heightmap.py b/tooling/domains/atlas/planet/render_heightmap.py similarity index 95% rename from tooling/planet-gen/render_heightmap.py rename to tooling/domains/atlas/planet/render_heightmap.py index 72d551e3d..8508e3d0b 100644 --- a/tooling/planet-gen/render_heightmap.py +++ b/tooling/domains/atlas/planet/render_heightmap.py @@ -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") diff --git a/tooling/domains/atlas/planet/router.py b/tooling/domains/atlas/planet/router.py new file mode 100644 index 000000000..8c20eaf8c --- /dev/null +++ b/tooling/domains/atlas/planet/router.py @@ -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 ~15–20 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)) diff --git a/tooling/planet-gen/scaffold_bodies.py b/tooling/domains/atlas/planet/scaffold_bodies.py similarity index 80% rename from tooling/planet-gen/scaffold_bodies.py rename to tooling/domains/atlas/planet/scaffold_bodies.py index 09713b281..c140b6967 100644 --- a/tooling/planet-gen/scaffold_bodies.py +++ b/tooling/domains/atlas/planet/scaffold_bodies.py @@ -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__": diff --git a/tooling/planet-gen/sol_data/__init__.py b/tooling/domains/atlas/planet/sol_data/__init__.py similarity index 100% rename from tooling/planet-gen/sol_data/__init__.py rename to tooling/domains/atlas/planet/sol_data/__init__.py diff --git a/tooling/planet-gen/sol_data/download.py b/tooling/domains/atlas/planet/sol_data/download.py similarity index 66% rename from tooling/planet-gen/sol_data/download.py rename to tooling/domains/atlas/planet/sol_data/download.py index e785db536..c19f1eab1 100644 --- a/tooling/planet-gen/sol_data/download.py +++ b/tooling/domains/atlas/planet/sol_data/download.py @@ -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() diff --git a/tooling/planet-gen/sol_data/earth.py b/tooling/domains/atlas/planet/sol_data/earth.py similarity index 92% rename from tooling/planet-gen/sol_data/earth.py rename to tooling/domains/atlas/planet/sol_data/earth.py index 910e77ed9..e521e799e 100644 --- a/tooling/planet-gen/sol_data/earth.py +++ b/tooling/domains/atlas/planet/sol_data/earth.py @@ -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] diff --git a/tooling/planet-gen/sol_data/gas_giants.py b/tooling/domains/atlas/planet/sol_data/gas_giants.py similarity index 87% rename from tooling/planet-gen/sol_data/gas_giants.py rename to tooling/domains/atlas/planet/sol_data/gas_giants.py index fc0f0ba85..e4ae7bae8 100644 --- a/tooling/planet-gen/sol_data/gas_giants.py +++ b/tooling/domains/atlas/planet/sol_data/gas_giants.py @@ -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 diff --git a/tooling/planet-gen/sol_data/ice_moons.py b/tooling/domains/atlas/planet/sol_data/ice_moons.py similarity index 92% rename from tooling/planet-gen/sol_data/ice_moons.py rename to tooling/domains/atlas/planet/sol_data/ice_moons.py index 1f65327da..4f253c59a 100644 --- a/tooling/planet-gen/sol_data/ice_moons.py +++ b/tooling/domains/atlas/planet/sol_data/ice_moons.py @@ -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) diff --git a/tooling/planet-gen/sol_data/io_moon.py b/tooling/domains/atlas/planet/sol_data/io_moon.py similarity index 91% rename from tooling/planet-gen/sol_data/io_moon.py rename to tooling/domains/atlas/planet/sol_data/io_moon.py index 45ab88341..d8868d26a 100644 --- a/tooling/planet-gen/sol_data/io_moon.py +++ b/tooling/domains/atlas/planet/sol_data/io_moon.py @@ -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() diff --git a/tooling/planet-gen/sol_data/luna.py b/tooling/domains/atlas/planet/sol_data/luna.py similarity index 86% rename from tooling/planet-gen/sol_data/luna.py rename to tooling/domains/atlas/planet/sol_data/luna.py index 17d573370..9a1215486 100644 --- a/tooling/planet-gen/sol_data/luna.py +++ b/tooling/domains/atlas/planet/sol_data/luna.py @@ -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) diff --git a/tooling/planet-gen/sol_data/mars.py b/tooling/domains/atlas/planet/sol_data/mars.py similarity index 90% rename from tooling/planet-gen/sol_data/mars.py rename to tooling/domains/atlas/planet/sol_data/mars.py index 2b5b7aac5..4fdd4c620 100644 --- a/tooling/planet-gen/sol_data/mars.py +++ b/tooling/domains/atlas/planet/sol_data/mars.py @@ -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 ──────────────────────────────────────────────────── diff --git a/tooling/planet-gen/sol_data/mercury.py b/tooling/domains/atlas/planet/sol_data/mercury.py similarity index 81% rename from tooling/planet-gen/sol_data/mercury.py rename to tooling/domains/atlas/planet/sol_data/mercury.py index 58fc69f8f..77fe7ea81 100644 --- a/tooling/planet-gen/sol_data/mercury.py +++ b/tooling/domains/atlas/planet/sol_data/mercury.py @@ -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) diff --git a/tooling/planet-gen/sol_data/shared.py b/tooling/domains/atlas/planet/sol_data/shared.py similarity index 100% rename from tooling/planet-gen/sol_data/shared.py rename to tooling/domains/atlas/planet/sol_data/shared.py diff --git a/tooling/planet-gen/sol_data/titan.py b/tooling/domains/atlas/planet/sol_data/titan.py similarity index 91% rename from tooling/planet-gen/sol_data/titan.py rename to tooling/domains/atlas/planet/sol_data/titan.py index 6efb31482..bb38ca09b 100644 --- a/tooling/planet-gen/sol_data/titan.py +++ b/tooling/domains/atlas/planet/sol_data/titan.py @@ -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) diff --git a/tooling/planet-gen/sol_data/venus.py b/tooling/domains/atlas/planet/sol_data/venus.py similarity index 82% rename from tooling/planet-gen/sol_data/venus.py rename to tooling/domains/atlas/planet/sol_data/venus.py index 772776fe5..b365f4374 100644 --- a/tooling/planet-gen/sol_data/venus.py +++ b/tooling/domains/atlas/planet/sol_data/venus.py @@ -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) diff --git a/tooling/planet-gen/sol_import.py b/tooling/domains/atlas/planet/sol_import.py similarity index 78% rename from tooling/planet-gen/sol_import.py rename to tooling/domains/atlas/planet/sol_import.py index bb5e1878d..a6855e4c5 100644 --- a/tooling/planet-gen/sol_import.py +++ b/tooling/domains/atlas/planet/sol_import.py @@ -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__": diff --git a/tooling/planet-gen/sol_markers/earth_features.json b/tooling/domains/atlas/planet/sol_markers/earth_features.json similarity index 100% rename from tooling/planet-gen/sol_markers/earth_features.json rename to tooling/domains/atlas/planet/sol_markers/earth_features.json diff --git a/tooling/planet-gen/sol_markers/luna_features.json b/tooling/domains/atlas/planet/sol_markers/luna_features.json similarity index 100% rename from tooling/planet-gen/sol_markers/luna_features.json rename to tooling/domains/atlas/planet/sol_markers/luna_features.json diff --git a/tooling/planet-gen/sol_markers/mars_features.json b/tooling/domains/atlas/planet/sol_markers/mars_features.json similarity index 100% rename from tooling/planet-gen/sol_markers/mars_features.json rename to tooling/domains/atlas/planet/sol_markers/mars_features.json diff --git a/tooling/planet-gen/sol_markers/outer_features.json b/tooling/domains/atlas/planet/sol_markers/outer_features.json similarity index 100% rename from tooling/planet-gen/sol_markers/outer_features.json rename to tooling/domains/atlas/planet/sol_markers/outer_features.json diff --git a/tooling/planet-gen/sol_name_fixes.py b/tooling/domains/atlas/planet/sol_name_fixes.py similarity index 89% rename from tooling/planet-gen/sol_name_fixes.py rename to tooling/domains/atlas/planet/sol_name_fixes.py index e33c61fc6..bf181d7d0 100644 --- a/tooling/planet-gen/sol_name_fixes.py +++ b/tooling/domains/atlas/planet/sol_name_fixes.py @@ -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() diff --git a/tooling/planet-gen/sol_overrides.json b/tooling/domains/atlas/planet/sol_overrides.json similarity index 100% rename from tooling/planet-gen/sol_overrides.json rename to tooling/domains/atlas/planet/sol_overrides.json diff --git a/tooling/domains/atlas/router.py b/tooling/domains/atlas/router.py index 418ccf200..923190f4c 100644 --- a/tooling/domains/atlas/router.py +++ b/tooling/domains/atlas/router.py @@ -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") diff --git a/tooling/domains/pr/service.py b/tooling/domains/pr/service.py index c9dc05d40..4bac64d97 100644 --- a/tooling/domains/pr/service.py +++ b/tooling/domains/pr/service.py @@ -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/", diff --git a/tooling/planet-gen/batch b/tooling/planet-gen/batch deleted file mode 100755 index 0136945b6..000000000 --- a/tooling/planet-gen/batch +++ /dev/null @@ -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" "$@" diff --git a/tooling/planet-gen/generate b/tooling/planet-gen/generate deleted file mode 100755 index f183b9098..000000000 --- a/tooling/planet-gen/generate +++ /dev/null @@ -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" "$@" diff --git a/tooling/test_conformance.py b/tooling/test_conformance.py index ac92771ed..fda1e5c30 100644 --- a/tooling/test_conformance.py +++ b/tooling/test_conformance.py @@ -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( diff --git a/tooling/planet-gen/test_sim_determinism.py b/tooling/test_planet_determinism.py similarity index 86% rename from tooling/planet-gen/test_sim_determinism.py rename to tooling/test_planet_determinism.py index fd28fcfc6..9a7ec6278 100644 --- a/tooling/planet-gen/test_sim_determinism.py +++ b/tooling/test_planet_determinism.py @@ -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: diff --git a/tooling/planet-gen/test_oasis_ring_scaling.py b/tooling/test_planet_oasis_rings.py similarity index 90% rename from tooling/planet-gen/test_oasis_ring_scaling.py rename to tooling/test_planet_oasis_rings.py index abee09666..7cd53f8b8 100644 --- a/tooling/planet-gen/test_oasis_ring_scaling.py +++ b/tooling/test_planet_oasis_rings.py @@ -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): diff --git a/tooling/test_planet_router.py b/tooling/test_planet_router.py new file mode 100644 index 000000000..6c66c7d65 --- /dev/null +++ b/tooling/test_planet_router.py @@ -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())