refactor(tooling): T-1288 — planet-gen becomes reach atlas planet
The 30-file tree moves under atlas as its third rung (D-243), ten verbs fronting it. Each verb restates its module's options so `--help` describes something; tooling/test_planet_router.py hands every declared option to the module's own argparse and fails on drift, and now runs in make test-tooling. The 2026-09-02 half of this move had converted the top-level imports and the repo roots. Finishing it found what the half-move left: - Lazy in-function imports, and all of sol_data/, still named siblings bare. They resolved only through sys.path.insert hacks, so under reach the first globe render in generate, batch or sol-import would have raised ModuleNotFoundError. Qualified; the hacks are gone. - 247 print() calls and a stdout progress writer that fired once per 8 KB block. Report verbs (audit, quality) write through console.out, progress through console.event, and download progress is throttled to 10% steps so a job log is not tens of thousands of lines. - Every error exit raises ReachError with a fix. Two checks that could not fail: - batch --verify-determinism printed a warning and exited 0 on a mismatch. - import-provinces exited 0 with errors > 0. Both now raise. The 271-body bake is only safe to re-run because the first one holds. sol-import --body is action="append" in the module but the router took one value, so --body GJ0d --body GJ0e kept one. Now repeatable, and _flags repeats list options. test_conformance walked one level, so a nested group was reported as a verb missing @command and its ten verbs were never checked. It recurses now; proven by stripping @command from `planet quality` and watching it fail. Stray PNGs from the 2026-09-03 runaway router-test run are parked in .cache/t1288-stray-pngs/, not committed. Their reliefmaps differ from HEAD while the heightmap regenerated byte-identical — filed as T-1291. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -289,3 +289,59 @@ and 3 paths in `docs/architecture/character-asset-organization.md`.
|
||||
Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks),
|
||||
`test_lazy_domains` now sees 10 domains.', NULL, '2026-09-02 18:55:28', '2026-09-02 18:55:28.421', '2026-09-02 18:55:28.421', NULL, '1352ceb77da9a63b23ee98f26fa76b1e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G5FZDN9YBP0MZ021N3TJCFXM', 'status', 'backlog', 'done', NULL, '2026-09-02 18:55:28', '2026-09-02 18:55:28.441', '2026-09-02 18:55:28.441', NULL, 'effde7946e61e7d9e8cdbaf14324676d', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65NSJ4RG6SJ12TQE78S6TKR', 'description', 'The largest single port left: `tooling/planet-gen/` (30 Python files) becomes
|
||||
`reach atlas planet <verb>`, the third rung of the spatial ladder (D-243), per
|
||||
the nesting the domain map fixed in T-1271 — `planet` is NOT a top-level domain,
|
||||
it lives under `atlas` because atlas coordinates world-location generation.
|
||||
|
||||
BEFORE MOVING ANYTHING, check `__file__`-relative roots. Five ports so far have
|
||||
hit this and it fails SILENTLY: a script that computed `Path(__file__).parent.parent`
|
||||
as the repo root resolves somewhere else once it lives two directories deeper,
|
||||
and the usual symptom is a gate that reports "nothing to check, OK" and exits 0.
|
||||
`validate-checklist` did exactly that. Grep for `__file__` first and convert to
|
||||
`config.repo_root()` / `config.path(...)` as part of the move, not after.
|
||||
|
||||
WATCH the ones that are already gated:
|
||||
- `test_sim_determinism.py` and `test_oasis_ring_scaling.py` are run by
|
||||
`make test-tooling` and must keep working — they are tests, not verbs. Decide
|
||||
whether they move with the domain or stay as test scripts, and update the
|
||||
Makefile either way.
|
||||
- Anything registered in `tooling/canvas_sources.py` (the canvas-generation
|
||||
version gate, T-1242) — moving a path there without updating the registry
|
||||
breaks `make check-canvas-version`, which has no override.
|
||||
- `import_heightmaps` / `import_province_boundaries` are one-time build imports,
|
||||
deliberately NOT stamped (`.claude/rules/asset-pipeline.md`). Do not fold them
|
||||
into the regen path while porting.
|
||||
|
||||
Standard port acceptance as on T-1281/T-1286: output parity (exit codes match
|
||||
exactly, no fact lost, failures name a remedy), every command carries `@command`,
|
||||
nothing prints but `console`, no `subprocess` outside `core/process`.', 'The largest single port left: `tooling/planet-gen/` (30 Python files) becomes
|
||||
`reach atlas planet <verb>`, the third rung of the spatial ladder (D-243), per
|
||||
the nesting the domain map fixed in T-1271 — `planet` is NOT a top-level domain,
|
||||
it lives under `atlas` because atlas coordinates world-location generation.
|
||||
|
||||
BEFORE MOVING ANYTHING, check `__file__`-relative roots. Five ports so far have
|
||||
hit this and it fails SILENTLY: a script that computed `Path(__file__).parent.parent`
|
||||
as the repo root resolves somewhere else once it lives two directories deeper,
|
||||
and the usual symptom is a gate that reports "nothing to check, OK" and exits 0.
|
||||
`validate-checklist` did exactly that. Grep for `__file__` first and convert to
|
||||
`config.repo_root()` / `config.path(...)` as part of the move, not after.
|
||||
|
||||
WATCH the ones that are already gated:
|
||||
- `test_sim_determinism.py` and `test_oasis_ring_scaling.py` are run by
|
||||
`make test-tooling` and must keep working — they are tests, not verbs. Decide
|
||||
whether they move with the domain or stay as test scripts, and update the
|
||||
Makefile either way.
|
||||
- Anything registered in `tooling/canvas_sources.py` (the canvas-generation
|
||||
version gate, T-1242) — moving a path there without updating the registry
|
||||
breaks `make check-canvas-version`, which has no override.
|
||||
- `import_heightmaps` / `import_province_boundaries` are one-time build imports,
|
||||
deliberately NOT stamped (`.claude/rules/asset-pipeline.md`). Do not fold them
|
||||
into the regen path while porting.
|
||||
|
||||
Standard port acceptance as on T-1281/T-1286: output parity (exit codes match
|
||||
exactly, no fact lost, failures name a remedy), every command carries `@command`,
|
||||
nothing prints but `console`, no `subprocess` outside `core/process`.
|
||||
|
||||
DONE 2026-09-23. reach atlas planet: ten verbs over the moved package; tooling/test_planet_router.py (in make test-tooling) fails if a router option drifts from its module''s argparse. Finishing the 2026-09-02 half-move found: (1) lazy in-function imports and all of sol_data/ still used bare sibling names that only resolved via sys.path hacks — generate/batch/sol-import would have failed on first globe render under reach; qualified, hacks removed. (2) 247 print() + a per-8KB-block stdout progress writer routed through console (report verbs -> out, progress -> event, throttled download progress). (3) Every error exit now raises ReachError with fix=. (4) TWO CHECKS THAT COULD NOT FAIL: batch --verify-determinism and import-provinces both exited 0 on detected failures; both now raise. (5) sol-import --body is action=append but the router took one value — now repeatable. (6) test_conformance only walked one level, so the nested group''s ten verbs were never checked; now recursive, proven against a mutant. Stray PNGs from the 2026-09-03 runaway test run (8 modified reliefmaps, 6 new heightmaps) parked in .cache/t1288-stray-pngs/, reliefmaps restored from HEAD. NOTE: the reliefmaps differed from the committed bake, i.e. the current renderer no longer reproduces committed reliefmap bytes — not investigated here.', NULL, '2026-09-23 14:07:42', '2026-09-23 14:07:42.675', '2026-09-23 14:07:42.675', NULL, 'b6c6d1e211eada353630e3946da96310', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65NSJ4RG6SJ12TQE78S6TKR', 'status', 'backlog', 'done', NULL, '2026-09-23 14:07:43', '2026-09-23 14:07:43.036', '2026-09-23 14:07:43.036', NULL, 'e3329ba1f64d0a63c635d4a9049d56b6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -442,3 +442,62 @@ and 3 paths in `docs/architecture/character-asset-organization.md`.
|
||||
|
||||
Gate: `ruff check tooling/` clean, `make test-tooling` PASS (13 checks),
|
||||
`test_lazy_domains` now sees 10 domains.', 'done', 'medium', NULL, NULL, 'D-263', '2026-08-31 13:23:59.951', '2026-09-02 18:55:28.441', NULL, '41fbc2c73d4d33a6f6224cf6e97f7aac', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65NSJ4RG6SJ12TQE78S6TKR', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port atlas planet — the 30-file planet-gen tree', 'The largest single port left: `tooling/planet-gen/` (30 Python files) becomes
|
||||
`reach atlas planet <verb>`, the third rung of the spatial ladder (D-243), per
|
||||
the nesting the domain map fixed in T-1271 — `planet` is NOT a top-level domain,
|
||||
it lives under `atlas` because atlas coordinates world-location generation.
|
||||
|
||||
BEFORE MOVING ANYTHING, check `__file__`-relative roots. Five ports so far have
|
||||
hit this and it fails SILENTLY: a script that computed `Path(__file__).parent.parent`
|
||||
as the repo root resolves somewhere else once it lives two directories deeper,
|
||||
and the usual symptom is a gate that reports "nothing to check, OK" and exits 0.
|
||||
`validate-checklist` did exactly that. Grep for `__file__` first and convert to
|
||||
`config.repo_root()` / `config.path(...)` as part of the move, not after.
|
||||
|
||||
WATCH the ones that are already gated:
|
||||
- `test_sim_determinism.py` and `test_oasis_ring_scaling.py` are run by
|
||||
`make test-tooling` and must keep working — they are tests, not verbs. Decide
|
||||
whether they move with the domain or stay as test scripts, and update the
|
||||
Makefile either way.
|
||||
- Anything registered in `tooling/canvas_sources.py` (the canvas-generation
|
||||
version gate, T-1242) — moving a path there without updating the registry
|
||||
breaks `make check-canvas-version`, which has no override.
|
||||
- `import_heightmaps` / `import_province_boundaries` are one-time build imports,
|
||||
deliberately NOT stamped (`.claude/rules/asset-pipeline.md`). Do not fold them
|
||||
into the regen path while porting.
|
||||
|
||||
Standard port acceptance as on T-1281/T-1286: output parity (exit codes match
|
||||
exactly, no fact lost, failures name a remedy), every command carries `@command`,
|
||||
nothing prints but `console`, no `subprocess` outside `core/process`.
|
||||
|
||||
DONE 2026-09-23. reach atlas planet: ten verbs over the moved package; tooling/test_planet_router.py (in make test-tooling) fails if a router option drifts from its module''s argparse. Finishing the 2026-09-02 half-move found: (1) lazy in-function imports and all of sol_data/ still used bare sibling names that only resolved via sys.path hacks — generate/batch/sol-import would have failed on first globe render under reach; qualified, hacks removed. (2) 247 print() + a per-8KB-block stdout progress writer routed through console (report verbs -> out, progress -> event, throttled download progress). (3) Every error exit now raises ReachError with fix=. (4) TWO CHECKS THAT COULD NOT FAIL: batch --verify-determinism and import-provinces both exited 0 on detected failures; both now raise. (5) sol-import --body is action=append but the router took one value — now repeatable. (6) test_conformance only walked one level, so the nested group''s ten verbs were never checked; now recursive, proven against a mutant. Stray PNGs from the 2026-09-03 runaway test run (8 modified reliefmaps, 6 new heightmaps) parked in .cache/t1288-stray-pngs/, reliefmaps restored from HEAD. NOTE: the reliefmaps differed from the committed bake, i.e. the current renderer no longer reproduces committed reliefmap bytes — not investigated here.', 'backlog', 'high', NULL, 'tooling', 'D-263', '2026-09-02 15:57:45.382', '2026-09-23 14:07:42.675', NULL, '71f6451699ed5f0d38612fe15d1cde35', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06G65NSJ4RG6SJ12TQE78S6TKR', 'task', '06G1S3D0M1TQW0GMFBBPQZG3ZM', 'Port atlas planet — the 30-file planet-gen tree', 'The largest single port left: `tooling/planet-gen/` (30 Python files) becomes
|
||||
`reach atlas planet <verb>`, the third rung of the spatial ladder (D-243), per
|
||||
the nesting the domain map fixed in T-1271 — `planet` is NOT a top-level domain,
|
||||
it lives under `atlas` because atlas coordinates world-location generation.
|
||||
|
||||
BEFORE MOVING ANYTHING, check `__file__`-relative roots. Five ports so far have
|
||||
hit this and it fails SILENTLY: a script that computed `Path(__file__).parent.parent`
|
||||
as the repo root resolves somewhere else once it lives two directories deeper,
|
||||
and the usual symptom is a gate that reports "nothing to check, OK" and exits 0.
|
||||
`validate-checklist` did exactly that. Grep for `__file__` first and convert to
|
||||
`config.repo_root()` / `config.path(...)` as part of the move, not after.
|
||||
|
||||
WATCH the ones that are already gated:
|
||||
- `test_sim_determinism.py` and `test_oasis_ring_scaling.py` are run by
|
||||
`make test-tooling` and must keep working — they are tests, not verbs. Decide
|
||||
whether they move with the domain or stay as test scripts, and update the
|
||||
Makefile either way.
|
||||
- Anything registered in `tooling/canvas_sources.py` (the canvas-generation
|
||||
version gate, T-1242) — moving a path there without updating the registry
|
||||
breaks `make check-canvas-version`, which has no override.
|
||||
- `import_heightmaps` / `import_province_boundaries` are one-time build imports,
|
||||
deliberately NOT stamped (`.claude/rules/asset-pipeline.md`). Do not fold them
|
||||
into the regen path while porting.
|
||||
|
||||
Standard port acceptance as on T-1281/T-1286: output parity (exit codes match
|
||||
exactly, no fact lost, failures name a remedy), every command carries `@command`,
|
||||
nothing prints but `console`, no `subprocess` outside `core/process`.
|
||||
|
||||
DONE 2026-09-23. reach atlas planet: ten verbs over the moved package; tooling/test_planet_router.py (in make test-tooling) fails if a router option drifts from its module''s argparse. Finishing the 2026-09-02 half-move found: (1) lazy in-function imports and all of sol_data/ still used bare sibling names that only resolved via sys.path hacks — generate/batch/sol-import would have failed on first globe render under reach; qualified, hacks removed. (2) 247 print() + a per-8KB-block stdout progress writer routed through console (report verbs -> out, progress -> event, throttled download progress). (3) Every error exit now raises ReachError with fix=. (4) TWO CHECKS THAT COULD NOT FAIL: batch --verify-determinism and import-provinces both exited 0 on detected failures; both now raise. (5) sol-import --body is action=append but the router took one value — now repeatable. (6) test_conformance only walked one level, so the nested group''s ten verbs were never checked; now recursive, proven against a mutant. Stray PNGs from the 2026-09-03 runaway test run (8 modified reliefmaps, 6 new heightmaps) parked in .cache/t1288-stray-pngs/, reliefmaps restored from HEAD. NOTE: the reliefmaps differed from the committed bake, i.e. the current renderer no longer reproduces committed reliefmap bytes — not investigated here.', 'done', 'high', NULL, 'tooling', 'D-263', '2026-09-02 15:57:45.382', '2026-09-23 14:07:43.036', NULL, '2a78f8f8646d915600ed55c5dc5d0993', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCX60QRMD7KJ1TVWTR7FWK28', 'bug', '06FBPPMZNNEV052DBYYY3A897C', 'Planet renderer no longer reproduces committed reliefmap.png bytes (8 bodies differ on re-render)', 'Found during T-1288. A 2026-09-03 accidental generate run re-rendered bodies in GJ-1002, GJ-1116B, GJ-139, GJ-147, GJ-216A, GJ-234A, GJ-273, GJ-282B: every reliefmap.png came back a few hundred bytes different from HEAD, while GJ1002b''s heightmap.png came back byte-identical (so the simulation is deterministic — make test-tooling confirms — and the drift is in the reliefmap RENDER path or its PNG encoding). Copies of the re-rendered files are in .cache/t1288-stray-pngs/. Question to answer first: is this a visible change (renderer code evolved since the April bake) or encoder-level noise (zlib/Pillow version, metadata)? Pixel-diff one pair before anything else. If visible: decide whether committed reliefmaps should be re-baked. If noise: make the encode deterministic so a re-render is a no-op in git.', 'backlog', 'low', NULL, 'tooling', NULL, '2026-09-23 14:07:49.189', '2026-09-23 14:07:49.189', NULL, '215478973901c77cdd0e6b5c446ff19e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
|
||||
@@ -277,23 +277,26 @@ test-ipc-benchmark:
|
||||
VENV_PY := $(shell test -x .venv/bin/python && echo .venv/bin/python || echo python3)
|
||||
|
||||
# Tooling test gate (T-1066) — called by pre-push when tooling/ changed.
|
||||
# 1. planet-gen determinism guard (#963): simulating the same body twice must
|
||||
# 1. atlas planet determinism guard (#963): simulating the same body twice must
|
||||
# be bit-identical (guards the expensive 271-body heightmap bake).
|
||||
# Exit 2 = no testable body found — warn, don't block.
|
||||
# 2. import_economics --dry-run against the committed DB: full parse +
|
||||
# structural/coverage validation, no writes. Exit 2 = coverage-gate
|
||||
# warning (D-175) — tolerated, matching regen-db's treatment.
|
||||
test-tooling:
|
||||
@echo " [test-tooling] planet-gen determinism guard (#963)..."
|
||||
@rc=0; $(VENV_PY) tooling/planet-gen/test_sim_determinism.py || rc=$$?; \
|
||||
@echo " [test-tooling] atlas planet determinism guard (#963)..."
|
||||
@rc=0; $(VENV_PY) tooling/test_planet_determinism.py || rc=$$?; \
|
||||
if [ $$rc -eq 2 ]; then \
|
||||
echo " WARNING: determinism guard found no testable body (exit 2) — not blocking"; \
|
||||
elif [ $$rc -ne 0 ]; then \
|
||||
echo " FAIL: planet_simulation determinism drift (exit $$rc)"; exit $$rc; \
|
||||
fi
|
||||
@echo " [test-tooling] oasis ring-scaling pin (T-964, PR #210 review)..."
|
||||
@$(VENV_PY) tooling/planet-gen/test_oasis_ring_scaling.py 2> .cache/test-tooling-oasis-ring.log || \
|
||||
@$(VENV_PY) tooling/test_planet_oasis_rings.py 2> .cache/test-tooling-oasis-ring.log || \
|
||||
{ echo " FAIL: oasis ring scaling — log follows:"; cat .cache/test-tooling-oasis-ring.log; exit 1; }
|
||||
@echo " [test-tooling] reach atlas planet options vs module parsers (T-1288)..."
|
||||
@$(VENV_PY) tooling/test_planet_router.py 2> .cache/test-tooling-planet-router.log || \
|
||||
{ echo " FAIL: planet router drift — log follows:"; cat .cache/test-tooling-planet-router.log; exit 1; }
|
||||
@echo " [test-tooling] canvas-generation version gate units (T-1242)..."
|
||||
@mkdir -p .cache
|
||||
@python3 tooling/test_canvas_version_check.py 2> .cache/test-tooling-canvas-version.log || \
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Atlas Hand-Refine Log — Sprint 36
|
||||
|
||||
Tracks all hand-edits to markers.json files above the batch Gemma 2 pass (#833).
|
||||
Re-run `python3 tooling/planet-gen/atlas_quality_analysis.py` after each entry to verify metrics improved.
|
||||
Re-run `reach atlas planet quality` after each entry to verify metrics improved.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,7 +58,7 @@ River and ocean collisions (Rio Grande ×23 rivers, Steinbruch ×19 rivers, Ridg
|
||||
## Sprint 36 — Core-World Cohesion Pass (#849)
|
||||
|
||||
**Analysis date:** 2026-04-19
|
||||
**Analysis script:** `tooling/planet-gen/atlas_quality_analysis.py`
|
||||
**Analysis script:** `reach atlas planet quality`
|
||||
|
||||
### Key findings from analysis
|
||||
|
||||
@@ -148,7 +148,7 @@ River and ocean collisions (Rio Grande ×23 rivers, Steinbruch ×19 rivers, Ridg
|
||||
- **"Jade Harbor" city on 20 bodies:** Widespread collision in generated content. The Gemma batch used this as a fallback east_reach city name. Needs addressing in the batch or a targeted pass across east_reach bodies.
|
||||
- **"Rio Grande" river on 23 bodies:** Portuguese fallback. Fix in the south_reach naming templates or targeted pass.
|
||||
|
||||
*See also: `tooling/planet-gen/refine_log_849.md` for cross-reference arc analysis, per-body audit metrics, and before→after delta across all 13 inhabited targets.*
|
||||
*See also: `docs/sprints/refine-log-T-849.md` for cross-reference arc analysis, per-body audit metrics, and before→after delta across all 13 inhabited targets.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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` |
|
||||
|
||||
@@ -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/`.
|
||||
"""
|
||||
+39
-39
@@ -13,19 +13,22 @@ identify names that need hand-refining:
|
||||
6. Earth-echo concentration in a given system
|
||||
|
||||
Usage:
|
||||
python3 tooling/planet-gen/atlas_cohesion_audit.py
|
||||
python3 tooling/planet-gen/atlas_cohesion_audit.py --system GJ 144
|
||||
python3 tooling/planet-gen/atlas_cohesion_audit.py --body GJ144d
|
||||
reach atlas planet audit
|
||||
reach atlas planet audit --system GJ 144
|
||||
reach atlas planet audit --body GJ144d
|
||||
|
||||
Decisions: D-191 (atlas pipeline, corridor palettes, markers.json format)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = (Path(__file__).resolve().parent / ".." / "..").resolve()
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
|
||||
REPO_ROOT = config.repo_root()
|
||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
|
||||
CARDINALS = ("North", "South", "East", "West", "Northern", "Southern",
|
||||
@@ -59,9 +62,9 @@ def get_conn(db_path: Path) -> sqlite3.Connection:
|
||||
|
||||
|
||||
def section(title: str) -> None:
|
||||
print(f"\n{'='*70}")
|
||||
print(f" {title}")
|
||||
print('='*70)
|
||||
console.out(f"\n{'='*70}")
|
||||
console.out(f" {title}")
|
||||
console.out('='*70)
|
||||
|
||||
|
||||
def run_all_features_for_body(conn, body_id: str) -> list[tuple[str, str]]:
|
||||
@@ -107,10 +110,10 @@ def report_empty_names(conn, system_id: str | None, body_id: str | None) -> None
|
||||
f"AND (name = '' OR LENGTH(name) < 2)", params
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
print(f" [{table}] {r['body_id']}/{r['local_id']}: '{r['name']}'")
|
||||
console.out(f" [{table}] {r['body_id']}/{r['local_id']}: '{r['name']}'")
|
||||
found += 1
|
||||
if not found:
|
||||
print(" None found.")
|
||||
console.out(" None found.")
|
||||
|
||||
|
||||
def report_generic_lazy(conn, system_id: str | None, body_id: str | None) -> None:
|
||||
@@ -143,11 +146,11 @@ def report_generic_lazy(conn, system_id: str | None, body_id: str | None) -> Non
|
||||
hits[key] = (r['body_id'], r['local_id'], r['name'], label)
|
||||
|
||||
for key, (bid, lid, name, lbl) in sorted(hits.items()):
|
||||
print(f" [{lbl}] {bid}/{lid}: '{name}'")
|
||||
console.out(f" [{lbl}] {bid}/{lid}: '{name}'")
|
||||
found += 1
|
||||
|
||||
if not found:
|
||||
print(" None found.")
|
||||
console.out(" None found.")
|
||||
|
||||
|
||||
def report_cardinals(conn, system_id: str | None, body_id: str | None) -> None:
|
||||
@@ -178,12 +181,12 @@ def report_cardinals(conn, system_id: str | None, body_id: str | None) -> None:
|
||||
)
|
||||
|
||||
if not by_body:
|
||||
print(" None found.")
|
||||
console.out(" None found.")
|
||||
return
|
||||
for bid, entries in sorted(by_body.items()):
|
||||
print(f" {bid}: {len(entries)} cardinal name(s)")
|
||||
console.out(f" {bid}: {len(entries)} cardinal name(s)")
|
||||
for lbl, lid, name in entries:
|
||||
print(f" [{lbl}] {lid}: '{name}'")
|
||||
console.out(f" [{lbl}] {lid}: '{name}'")
|
||||
|
||||
|
||||
def report_earth_echoes(conn, system_id: str | None, body_id: str | None) -> None:
|
||||
@@ -215,12 +218,12 @@ def report_earth_echoes(conn, system_id: str | None, body_id: str | None) -> Non
|
||||
)
|
||||
|
||||
if not by_body:
|
||||
print(" None found.")
|
||||
console.out(" None found.")
|
||||
return
|
||||
for bid, entries in sorted(by_body.items()):
|
||||
print(f" {bid}: {len(entries)} earth-echo(s)")
|
||||
console.out(f" {bid}: {len(entries)} earth-echo(s)")
|
||||
for lbl, lid, name in entries:
|
||||
print(f" [{lbl}] {lid}: '{name}'")
|
||||
console.out(f" [{lbl}] {lid}: '{name}'")
|
||||
|
||||
|
||||
def report_same_body_stem_dupes(conn, system_id: str | None, body_id: str | None) -> None:
|
||||
@@ -251,18 +254,18 @@ def report_same_body_stem_dupes(conn, system_id: str | None, body_id: str | None
|
||||
if len(items) > 1:
|
||||
types = set(i[0] for i in items)
|
||||
if len(types) > 1: # only flag cross-feature (different types)
|
||||
print(f" {bid} stem='{stem}':")
|
||||
console.out(f" {bid} stem='{stem}':")
|
||||
for lbl, nm in items:
|
||||
print(f" [{lbl}] '{nm}'")
|
||||
console.out(f" [{lbl}] '{nm}'")
|
||||
found += 1
|
||||
if not found:
|
||||
print(" None found.")
|
||||
console.out(" None found.")
|
||||
|
||||
|
||||
def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
|
||||
section("CROSS-BODY STEM DUPLICATES WITHIN SYSTEM (same corridor)")
|
||||
if not system_id:
|
||||
print(" (requires --system; skipped)")
|
||||
console.out(" (requires --system; skipped)")
|
||||
return
|
||||
|
||||
bodies_q = conn.execute(
|
||||
@@ -291,13 +294,13 @@ def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
|
||||
for stem, hits in sorted(all_names.items()):
|
||||
bodies_hit = set(h[0] for h in hits)
|
||||
if len(bodies_hit) > 1:
|
||||
print(f" stem='{stem}' appears in {len(bodies_hit)} bodies:")
|
||||
console.out(f" stem='{stem}' appears in {len(bodies_hit)} bodies:")
|
||||
for bid, label, name in hits:
|
||||
print(f" {bid} [{label}] '{name}'")
|
||||
console.out(f" {bid} [{label}] '{name}'")
|
||||
found += 1
|
||||
|
||||
if not found:
|
||||
print(" None found.")
|
||||
console.out(" None found.")
|
||||
|
||||
|
||||
def report_summary_score(conn, system_id: str | None, body_id: str | None) -> None:
|
||||
@@ -317,7 +320,7 @@ def report_summary_score(conn, system_id: str | None, body_id: str | None) -> No
|
||||
f"SELECT body_id, proper_name, population FROM bodies {where}", params
|
||||
).fetchall()
|
||||
|
||||
print(f" {'body_id':<20} {'name':<20} {'pop':<12} cities rivers oceans mounts pois")
|
||||
console.out(f" {'body_id':<20} {'name':<20} {'pop':<12} cities rivers oceans mounts pois")
|
||||
for row in bodies_q:
|
||||
bid = row["body_id"]
|
||||
name = row["proper_name"] or ""
|
||||
@@ -333,24 +336,21 @@ def report_summary_score(conn, system_id: str | None, body_id: str | None) -> No
|
||||
).fetchone()[0]
|
||||
counts[key] = n
|
||||
|
||||
print(
|
||||
f" {bid:<20} {name:<20} {pop:<12,} "
|
||||
console.out(f" {bid:<20} {name:<20} {pop:<12,} "
|
||||
f"{counts['c']:>5} {counts['r']:>6} {counts['o']:>6} "
|
||||
f"{counts['m']:>6} {counts['p']:>4}"
|
||||
)
|
||||
f"{counts['m']:>6} {counts['p']:>4}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--system", help="GJ catalog ID (e.g. 'GJ 144')")
|
||||
parser.add_argument("--body", help="Body ID (e.g. 'GJ144d')")
|
||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
db = Path(args.db)
|
||||
if not db.exists():
|
||||
print(f"error: database not found at {db}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"database not found at {db}", fix="make regen-db, or point --db at an existing systems.db")
|
||||
|
||||
conn = get_conn(db)
|
||||
|
||||
@@ -364,12 +364,12 @@ def main() -> None:
|
||||
if row:
|
||||
system_id = row["system_id"]
|
||||
|
||||
print("\nAtlas Cohesion Audit")
|
||||
print(f" DB: {db}")
|
||||
console.out("\nAtlas Cohesion Audit")
|
||||
console.out(f" DB: {db}")
|
||||
if system_id:
|
||||
print(f" System: {system_id}")
|
||||
console.out(f" System: {system_id}")
|
||||
if body_id:
|
||||
print(f" Body: {body_id}")
|
||||
console.out(f" Body: {body_id}")
|
||||
|
||||
report_summary_score(conn, system_id, body_id)
|
||||
report_empty_names(conn, system_id, body_id)
|
||||
@@ -381,7 +381,7 @@ def main() -> None:
|
||||
report_cross_body_stem_dupes(conn, system_id)
|
||||
|
||||
conn.close()
|
||||
print("\nDone.\n")
|
||||
console.out("\nDone.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
atlas_common.py — shared atlas-DB utilities for the planet-gen importers.
|
||||
atlas_common.py — shared atlas-DB utilities for the atlas planet importers.
|
||||
|
||||
Extracted from the retired generate_atlas.py (D-223, #951). The atlas city/road/
|
||||
river geometry *generator* was retired when authored geometry was dropped in
|
||||
@@ -20,13 +20,16 @@ Decisions: D-223 (authored content as flavoured name pool), D-191 (atlas index).
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths and grid constants
|
||||
# ---------------------------------------------------------------------------
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
PLANET_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = config.repo_root()
|
||||
|
||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
|
||||
+30
-30
@@ -10,10 +10,10 @@ Queries atlas_* tables in systems.db and reports on:
|
||||
5. Top-stem frequency across all named features
|
||||
|
||||
Usage:
|
||||
python3 tooling/planet-gen/atlas_quality_analysis.py [--db server/data/systems.db]
|
||||
python3 tooling/planet-gen/atlas_quality_analysis.py --system GJ380
|
||||
python3 tooling/planet-gen/atlas_quality_analysis.py --top-collisions 20
|
||||
python3 tooling/planet-gen/atlas_quality_analysis.py --body GJ71c
|
||||
reach atlas planet quality [--db server/data/systems.db]
|
||||
reach atlas planet quality --system GJ380
|
||||
reach atlas planet quality --top-collisions 20
|
||||
reach atlas planet quality --body GJ71c
|
||||
|
||||
D-191 §8: markers.json is pixel-space [row, col] against 512×256.
|
||||
Re-run after any hand-refine pass to verify improvements.
|
||||
@@ -23,9 +23,11 @@ import argparse
|
||||
import re
|
||||
import sqlite3
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
from tooling.core import config, console
|
||||
|
||||
|
||||
REPO_ROOT = config.repo_root()
|
||||
DEFAULT_DB = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
|
||||
CARDINAL_RE = re.compile(
|
||||
@@ -154,26 +156,26 @@ def run_analysis(args):
|
||||
if args.body:
|
||||
body_index = {k: v for k, v in body_index.items() if k == args.body}
|
||||
|
||||
print("=" * 70)
|
||||
print("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
|
||||
print(f"DB: {args.db}")
|
||||
console.out("=" * 70)
|
||||
console.out("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
|
||||
console.out(f"DB: {args.db}")
|
||||
if args.system:
|
||||
print(f"Filter: system={args.system}")
|
||||
console.out(f"Filter: system={args.system}")
|
||||
if args.body:
|
||||
print(f"Filter: body={args.body}")
|
||||
print("=" * 70)
|
||||
console.out(f"Filter: body={args.body}")
|
||||
console.out("=" * 70)
|
||||
|
||||
# --- 1. Cross-body collisions ---
|
||||
print("\n[ 1. CROSS-BODY NAME COLLISIONS ]")
|
||||
console.out("\n[ 1. CROSS-BODY NAME COLLISIONS ]")
|
||||
collisions = cross_body_collisions(conn, limit=args.top_collisions)
|
||||
for feat_type, rows in collisions.items():
|
||||
if rows:
|
||||
print(f"\n {feat_type}:")
|
||||
console.out(f"\n {feat_type}:")
|
||||
for name, cnt, bodies in rows:
|
||||
print(f" '{name}' — {cnt} bodies: {bodies[:80]}")
|
||||
console.out(f" '{name}' — {cnt} bodies: {bodies[:80]}")
|
||||
|
||||
# --- 2. Per-body quality scores ---
|
||||
print("\n[ 2. BODY QUALITY SCORES — ranked by collision % ]")
|
||||
console.out("\n[ 2. BODY QUALITY SCORES — ranked by collision % ]")
|
||||
reports = []
|
||||
for bid, info in body_index.items():
|
||||
names = all_names_by_body.get(bid, [])
|
||||
@@ -186,27 +188,25 @@ def run_analysis(args):
|
||||
|
||||
reports.sort(key=lambda x: -x[2]["colliding_pct"])
|
||||
|
||||
print(f"\n {'Body':<28} {'System':<12} {'Corridor':<15} "
|
||||
console.out(f"\n {'Body':<28} {'System':<12} {'Corridor':<15} "
|
||||
f"{'Coll%':>6} {'Card%':>6} {'Gen':>4} {'Echo':>4}")
|
||||
for bid, info, rep in reports[:30]:
|
||||
print(
|
||||
f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
|
||||
console.out(f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
|
||||
f"{rep['colliding_pct']:>6.0%} {rep['cardinal_pct']:>6.0%} "
|
||||
f"{rep['generic']:>4} {rep['earth_echo']:>4}"
|
||||
)
|
||||
f"{rep['generic']:>4} {rep['earth_echo']:>4}")
|
||||
|
||||
# --- 3. Stem frequency ---
|
||||
print("\n[ 3. TOP STEM FREQUENCY (first word of name) ]")
|
||||
console.out("\n[ 3. TOP STEM FREQUENCY (first word of name) ]")
|
||||
all_names_flat = [n for names in all_names_by_body.values() for _, n, _ in names]
|
||||
for stem, cnt in stem_frequency(all_names_flat, top_n=20):
|
||||
print(f" {stem:<20} {cnt}")
|
||||
console.out(f" {stem:<20} {cnt}")
|
||||
|
||||
# --- 4. Detailed body report (if --body specified) ---
|
||||
if args.body and args.body in all_names_by_body:
|
||||
bid = args.body
|
||||
info = body_index.get(bid, {})
|
||||
names = all_names_by_body[bid]
|
||||
print(f"\n[ 4. DETAILED REPORT: {bid} ({info.get('name', '?')}) ]")
|
||||
console.out(f"\n[ 4. DETAILED REPORT: {bid} ({info.get('name', '?')}) ]")
|
||||
c = conn.cursor()
|
||||
for feat_type, name, local_id in sorted(names, key=lambda x: x[0]):
|
||||
tbl = [t for t, f in FEATURE_TABLES if f == feat_type][0]
|
||||
@@ -218,29 +218,29 @@ def run_analysis(args):
|
||||
flag = f" *** COLLISION ×{others}" if others > 0 else ""
|
||||
cardinal = " [cardinal]" if CARDINAL_RE.search(name) else ""
|
||||
generic = " [generic]" if GENERIC_RE.search(name) else ""
|
||||
print(f" {feat_type:<10} {local_id:<12} {name}{flag}{cardinal}{generic}")
|
||||
console.out(f" {feat_type:<10} {local_id:<12} {name}{flag}{cardinal}{generic}")
|
||||
|
||||
# --- 5. Sol gap check ---
|
||||
print("\n[ 5. SOL SYSTEM GAP CHECK ]")
|
||||
console.out("\n[ 5. SOL SYSTEM GAP CHECK ]")
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT body_id, proper_name, population FROM bodies WHERE system_id='GJ 0' AND inhabited=1")
|
||||
sol_bodies = c.fetchall()
|
||||
for bid, bname, pop in sol_bodies:
|
||||
has_cities = bid in all_names_by_body and any(f == "city" for f, _, _ in all_names_by_body[bid])
|
||||
status = "HAS DATA" if has_cities else "*** EMPTY — needs authoring"
|
||||
print(f" {bid:<15} {bname or '?':<20} pop={pop or '?'} {status}")
|
||||
console.out(f" {bid:<15} {bname or '?':<20} pop={pop or '?'} {status}")
|
||||
|
||||
conn.close()
|
||||
print("\nDone.")
|
||||
console.out("\nDone.")
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv: list[str] | None = None):
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="Path to systems.db")
|
||||
parser.add_argument("--system", help="Filter to one system (e.g. GJ380)")
|
||||
parser.add_argument("--body", help="Filter to one body (e.g. GJ71c)")
|
||||
parser.add_argument("--top-collisions", type=int, default=15, help="Collision list limit")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
run_analysis(args)
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ Walks wiki/star-systems/, scaffolds body index.md files where missing,
|
||||
then generates heightmap + globe + terrain data for every body.
|
||||
|
||||
Usage:
|
||||
python3 batch.py # full run: scaffold + generate
|
||||
python3 batch.py --scaffold-only # just create body index.md files
|
||||
python3 batch.py --generate-only # just render (bodies must exist)
|
||||
python3 batch.py --system GJ-144 # single system
|
||||
python3 batch.py --system GJ-144 --body GJ144d # single body
|
||||
python3 batch.py --overrides sol.json # per-body overrides
|
||||
python3 batch.py --dry-run # validate data, don't generate
|
||||
reach atlas planet batch # full run: scaffold + generate
|
||||
reach atlas planet batch --scaffold-only # just create body index.md files
|
||||
reach atlas planet batch --generate-only # just render (bodies must exist)
|
||||
reach atlas planet batch --system GJ-144 # single system
|
||||
reach atlas planet batch --system GJ-144 --body GJ144d # single body
|
||||
reach atlas planet batch --overrides sol.json # per-body overrides
|
||||
reach atlas planet batch --dry-run # validate data, don't generate
|
||||
|
||||
Skips:
|
||||
- GJ-0 (Sol) — manual overrides required, use --system GJ-0 explicitly
|
||||
@@ -26,26 +26,31 @@ Error handling:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
# Venv bootstrap
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
PLANET_DIR = Path(__file__).resolve().parent
|
||||
WORKTREE_ROOT = config.repo_root()
|
||||
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
|
||||
# script under .venv/bin/python so numpy would resolve when run by path.
|
||||
# reach declares numpy and Pillow itself, so its own environment already has
|
||||
# them — and an os.execv into a different interpreter, carrying reach's
|
||||
# argv, would have relaunched something that is not this command at all.
|
||||
|
||||
import yaml
|
||||
import numpy as np
|
||||
|
||||
from body_definition_parser import parse_system
|
||||
from planet_simulation import simulate
|
||||
from render_heightmap import render_heightmap
|
||||
from tooling.domains.atlas.planet.body_definition_parser import parse_system
|
||||
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
|
||||
|
||||
|
||||
# Systems to skip in batch mode (require manual handling)
|
||||
SKIP_SYSTEMS = {"GJ-0"}
|
||||
@@ -165,7 +170,7 @@ def _scaffold_system(system_dir: Path, overrides: dict) -> list:
|
||||
if not index_md.exists():
|
||||
return []
|
||||
|
||||
from scaffold_bodies import _body_to_frontmatter, _body_prose
|
||||
from tooling.domains.atlas.planet.scaffold_bodies import _body_to_frontmatter, _body_prose
|
||||
|
||||
body_defs = parse_system(str(index_md), overrides=overrides)
|
||||
bodies_dir = system_dir / "bodies"
|
||||
@@ -247,7 +252,7 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
|
||||
_save_img(hmap_img, body_dir / "heightmap.png")
|
||||
|
||||
# Globe — atomic write
|
||||
from planet_renderer import render_globe
|
||||
from tooling.domains.atlas.planet.planet_renderer import render_globe
|
||||
globe_img = render_globe(bd, terrain, size=globe_size)
|
||||
_save_img(globe_img, body_dir / "globe.png")
|
||||
|
||||
@@ -262,14 +267,14 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
|
||||
np.savez_compressed(str(body_dir / "terrain.npz"), **save_dict)
|
||||
|
||||
# Markers
|
||||
from generate import _build_markers
|
||||
from tooling.domains.atlas.planet.generate import _build_markers
|
||||
markers = _build_markers(bd, terrain)
|
||||
_save(markers, body_dir / "markers.json",
|
||||
lambda m, p: Path(p).write_text(json.dumps(m, indent=2)))
|
||||
|
||||
elapsed = time.time() - t0
|
||||
kind = "gas" if is_gas else f"land={int((~terrain['surface_water']).sum())}"
|
||||
print(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
|
||||
console.event(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
|
||||
return "generated"
|
||||
|
||||
|
||||
@@ -277,7 +282,7 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
def main(argv: list[str] | None = None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Batch planet generation — all systems unattended")
|
||||
parser.add_argument("--system", help="Process only this system (dir name, e.g. GJ-144)")
|
||||
@@ -293,15 +298,14 @@ def main():
|
||||
help="Run N random bodies twice and verify identical output")
|
||||
parser.add_argument("--heightmap-size", default="1024x512")
|
||||
parser.add_argument("--globe-size", type=int, default=512)
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
hw, hh = args.heightmap_size.lower().split("x")
|
||||
hmap_w, hmap_h = int(hw), int(hh)
|
||||
|
||||
wiki_systems = WORKTREE_ROOT / "wiki" / "star-systems"
|
||||
if not wiki_systems.exists():
|
||||
print(f"error: {wiki_systems} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"{wiki_systems} not found", fix="run from a settled-reach checkout — make reach-repoint")
|
||||
|
||||
overrides = {}
|
||||
if args.overrides:
|
||||
@@ -312,8 +316,7 @@ def main():
|
||||
if args.system:
|
||||
system_dirs = [wiki_systems / args.system]
|
||||
if not system_dirs[0].exists():
|
||||
print(f"error: system {args.system} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"system {args.system} not found", fix="--system takes the wiki directory name, e.g. GJ-1002 (see wiki/star-systems/)")
|
||||
else:
|
||||
system_dirs = sorted([
|
||||
d for d in wiki_systems.iterdir()
|
||||
@@ -334,19 +337,18 @@ def main():
|
||||
total_invalid = 0
|
||||
error_rate_threshold = 0.50
|
||||
|
||||
print("\n Planet Generator — Batch Mode")
|
||||
print(f" Systems: {len(system_dirs)}")
|
||||
print(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
|
||||
console.event(" Planet Generator — Batch Mode")
|
||||
console.event(f" Systems: {len(system_dirs)}")
|
||||
console.event(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
|
||||
if args.dry_run:
|
||||
print(" Mode: DRY RUN (validation only)")
|
||||
print()
|
||||
console.event(" Mode: DRY RUN (validation only)")
|
||||
|
||||
for system_dir in system_dirs:
|
||||
system_id = system_dir.name
|
||||
|
||||
# Skip Sol in batch mode (needs manual overrides)
|
||||
if system_id in SKIP_SYSTEMS and not args.system:
|
||||
print(f" {system_id} — skipped (manual)")
|
||||
console.event(f" {system_id} — skipped (manual)")
|
||||
continue
|
||||
|
||||
system_name = _read_system_name(system_dir / "index.md")
|
||||
@@ -364,7 +366,7 @@ def main():
|
||||
except Exception:
|
||||
n_bodies = 0
|
||||
|
||||
print(f" {system_id} — {system_name} ({n_bodies} bodies)")
|
||||
console.event(f" {system_id} — {system_name} ({n_bodies} bodies)")
|
||||
|
||||
# ── Scaffold ─────────────────────────────────────────────────────
|
||||
if not args.generate_only and not args.dry_run:
|
||||
@@ -373,10 +375,10 @@ def main():
|
||||
if created:
|
||||
total_scaffolded += len(created)
|
||||
for bid in created:
|
||||
print(f" scaffolded {bid}")
|
||||
console.event(f" scaffolded {bid}")
|
||||
except Exception as e:
|
||||
tb = traceback.format_exc()
|
||||
print(f" SCAFFOLD ERROR: {e}")
|
||||
console.event(f" SCAFFOLD ERROR: {e}")
|
||||
_log_error(system_id, "*", str(e), tb)
|
||||
|
||||
# ── Validate / Generate ──────────────────────────────────────────
|
||||
@@ -412,12 +414,12 @@ def main():
|
||||
errors = _validate_body_def(bd, body_dir)
|
||||
if errors:
|
||||
total_invalid += 1
|
||||
print(f" {body_id:20s} ({body_name:20s}) INVALID")
|
||||
console.event(f" {body_id:20s} ({body_name:20s}) INVALID")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
console.event(f" - {err}")
|
||||
else:
|
||||
total_valid += 1
|
||||
print(f" {body_id:20s} ({body_name:20s}) ok")
|
||||
console.event(f" {body_id:20s} ({body_name:20s}) ok")
|
||||
continue
|
||||
|
||||
if args.scaffold_only:
|
||||
@@ -437,30 +439,30 @@ def main():
|
||||
except Exception as e:
|
||||
total_errors += 1
|
||||
tb = traceback.format_exc()
|
||||
print(f" {body_id:20s} ({body_name:20s}) ERROR: {e}")
|
||||
console.event(f" {body_id:20s} ({body_name:20s}) ERROR: {e}")
|
||||
_log_error(system_id, body_id, str(e), tb)
|
||||
|
||||
# Circuit breaker: abort if error rate is too high
|
||||
if total_attempted >= 10 and total_errors / total_attempted > error_rate_threshold:
|
||||
print(f"\n ABORT: error rate {total_errors}/{total_attempted} "
|
||||
console.event(f" ABORT: error rate {total_errors}/{total_attempted} "
|
||||
f"({total_errors/total_attempted*100:.0f}%) exceeds "
|
||||
f"{error_rate_threshold*100:.0f}% threshold")
|
||||
print(f" Check {LOG_PATH} for details")
|
||||
console.event(f" Check {LOG_PATH} for details")
|
||||
sys.exit(1)
|
||||
|
||||
elapsed = time.time() - t_total
|
||||
|
||||
print(f"\n Batch complete: {elapsed:.0f}s")
|
||||
console.event(f" Batch complete: {elapsed:.0f}s")
|
||||
if args.dry_run:
|
||||
print(f" valid: {total_valid}")
|
||||
print(f" invalid: {total_invalid}")
|
||||
console.event(f" valid: {total_valid}")
|
||||
console.event(f" invalid: {total_invalid}")
|
||||
else:
|
||||
print(f" scaffolded: {total_scaffolded}")
|
||||
print(f" generated: {total_generated}")
|
||||
print(f" skipped: {total_skipped}")
|
||||
print(f" errors: {total_errors}")
|
||||
console.event(f" scaffolded: {total_scaffolded}")
|
||||
console.event(f" generated: {total_generated}")
|
||||
console.event(f" skipped: {total_skipped}")
|
||||
console.event(f" errors: {total_errors}")
|
||||
if total_errors > 0:
|
||||
print(f" error log: {LOG_PATH}")
|
||||
console.event(f" error log: {LOG_PATH}")
|
||||
|
||||
# ── Determinism verification ─────────────────────────────────────────
|
||||
if args.verify_determinism > 0:
|
||||
@@ -475,7 +477,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
print(f"\n Determinism verification — {n_samples} samples")
|
||||
console.event(f" Determinism verification — {n_samples} samples")
|
||||
|
||||
# Collect all body dirs that have been generated
|
||||
all_body_dirs = []
|
||||
@@ -487,7 +489,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
||||
all_body_dirs.append(bd)
|
||||
|
||||
if not all_body_dirs:
|
||||
print(" no generated bodies to verify")
|
||||
console.event(" no generated bodies to verify")
|
||||
return
|
||||
|
||||
rng = np.random.default_rng(42)
|
||||
@@ -514,7 +516,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
||||
try:
|
||||
_generate_body_from_dir(tmp_body, hmap_w, hmap_h, globe_size, force=True)
|
||||
except Exception as e:
|
||||
print(f" {body_id}: generation failed — {e}")
|
||||
console.event(f" {body_id}: generation failed — {e}")
|
||||
shutil.rmtree(tmp_dir)
|
||||
failed += 1
|
||||
continue
|
||||
@@ -527,13 +529,13 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
||||
if not orig.exists() and not rerun.exists():
|
||||
continue
|
||||
if not orig.exists() or not rerun.exists():
|
||||
print(f" {body_id}: {fname} — missing in {'original' if not orig.exists() else 'rerun'}")
|
||||
console.event(f" {body_id}: {fname} — missing in {'original' if not orig.exists() else 'rerun'}")
|
||||
all_match = False
|
||||
continue
|
||||
h1 = hashlib.sha256(orig.read_bytes()).hexdigest()[:16]
|
||||
h2 = hashlib.sha256(rerun.read_bytes()).hexdigest()[:16]
|
||||
if h1 != h2:
|
||||
print(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
|
||||
console.event(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
|
||||
all_match = False
|
||||
|
||||
if all_match:
|
||||
@@ -543,9 +545,14 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
||||
|
||||
shutil.rmtree(tmp_dir)
|
||||
|
||||
print(f" passed: {passed} failed: {failed}")
|
||||
console.event(f" passed: {passed} failed: {failed}")
|
||||
if failed > 0:
|
||||
print(" WARNING: non-deterministic output detected!")
|
||||
# Was a printed warning with exit 0 — a determinism check that could not
|
||||
# fail (T-1288). The 271-body bake is only re-runnable because this holds.
|
||||
raise ReachError(
|
||||
f"non-deterministic output: {failed} of {passed + failed} bodies differ on re-run",
|
||||
fix="reach atlas planet batch --verify-determinism 1, then diff the MISMATCH files above",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -6,7 +6,7 @@ parameters. All three pipeline modules import from here instead of
|
||||
maintaining their own hardcoded tables.
|
||||
|
||||
Usage:
|
||||
from biome_config import (
|
||||
from tooling.domains.atlas.planet.biome_config import (
|
||||
WHITTAKER_TABLE, CLASS_T_BAND, BIOME_PALETTE,
|
||||
STAR_TINTS, ATMO_COLORS, GAS_PALETTES,
|
||||
EXOTIC_CLASSES, CRATER_SCALING, RIVER_RGB, COAST_RGB,
|
||||
+5
-4
@@ -21,10 +21,10 @@ Field resolution order (highest wins):
|
||||
5. randomised (seeded, within planet-class constraints)
|
||||
|
||||
Usage:
|
||||
python3 body_definition_parser.py path/to/index.md [--out-dir ./defs]
|
||||
python -m tooling.domains.atlas.planet.body_definition_parser path/to/index.md [--out-dir ./defs]
|
||||
|
||||
# With overrides (e.g. Sol)
|
||||
python3 body_definition_parser.py sol/index.md --overrides sol_overrides.json
|
||||
python -m tooling.domains.atlas.planet.body_definition_parser sol/index.md --overrides sol_overrides.json
|
||||
|
||||
Override file format:
|
||||
{
|
||||
@@ -206,7 +206,8 @@ CLASS_OBLATENESS = {
|
||||
}
|
||||
|
||||
# Gas giant band palettes available
|
||||
from biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
|
||||
from tooling.domains.atlas.planet.biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
|
||||
from tooling.core import console
|
||||
|
||||
# planet_class → cloud coverage base range
|
||||
CLASS_CLOUD = {
|
||||
@@ -855,4 +856,4 @@ if __name__ == "__main__":
|
||||
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
|
||||
|
||||
if args.print:
|
||||
print(json.dumps(defs, indent=2))
|
||||
console.out(json.dumps(defs, indent=2))
|
||||
@@ -20,21 +20,26 @@ Optional (spike/review only):
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
|
||||
from pathlib import Path
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
PLANET_DIR = Path(__file__).resolve().parent
|
||||
WORKTREE_ROOT = config.repo_root()
|
||||
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
|
||||
# script under .venv/bin/python so numpy would resolve when run by path.
|
||||
# reach declares numpy and Pillow itself, so its own environment already has
|
||||
# them — and an os.execv into a different interpreter, carrying reach's
|
||||
# argv, would have relaunched something that is not this command at all.
|
||||
|
||||
import numpy as np
|
||||
|
||||
from planet_simulation import simulate
|
||||
from render_heightmap import render_heightmap
|
||||
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
|
||||
|
||||
|
||||
|
||||
def _build_markers(body_def: dict, terrain: dict) -> dict:
|
||||
@@ -131,7 +136,7 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
planet_class = body_def.get("planet_class", "unknown")
|
||||
name = body_def.get("name") or body_id
|
||||
|
||||
print(f"\n {body_id} ({name}) — {planet_class}")
|
||||
console.event(f" {body_id} ({name}) — {planet_class}")
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
@@ -141,9 +146,9 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
t_sim = time.time()
|
||||
|
||||
if is_gas:
|
||||
print(f" simulate: gas giant ({t_sim - t0:.1f}s)")
|
||||
console.event(f" simulate: gas giant ({t_sim - t0:.1f}s)")
|
||||
else:
|
||||
print(f" simulate: {t_sim - t0:.1f}s "
|
||||
console.event(f" simulate: {t_sim - t0:.1f}s "
|
||||
f"sea={terrain['sea_level']:.3f} "
|
||||
f"land={int((~terrain['surface_water']).sum())}")
|
||||
|
||||
@@ -163,20 +168,20 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
render_mode=render_mode, chrome=True)
|
||||
chrome_path = f"/tmp/{body_id}_heightmap_chrome.png"
|
||||
hmap_chrome.save(chrome_path)
|
||||
print(f" chrome: {chrome_path}")
|
||||
console.event(f" chrome: {chrome_path}")
|
||||
|
||||
t_hmap = time.time()
|
||||
print(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
|
||||
console.event(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
|
||||
|
||||
# ── 3. Render globe ──────────────────────────────────────────────────
|
||||
try:
|
||||
from planet_renderer import render_globe
|
||||
from tooling.domains.atlas.planet.planet_renderer import render_globe
|
||||
globe_img = render_globe(body_def, terrain, size=globe_size)
|
||||
globe_img.save(os.path.join(body_dir, "globe.png"))
|
||||
t_globe = time.time()
|
||||
print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
|
||||
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
|
||||
except Exception as e:
|
||||
print(f" globe: FAILED — {e}")
|
||||
console.event(f" globe: FAILED — {e}")
|
||||
t_globe = time.time()
|
||||
|
||||
# ── 4. Write data files ──────────────────────────────────────────────
|
||||
@@ -198,10 +203,10 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
json.dump(markers, f, indent=2)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f" total: {elapsed:.1f}s → {body_dir}/")
|
||||
console.event(f" total: {elapsed:.1f}s → {body_dir}/")
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv: list[str] | None = None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Planet generator — heightmap + globe from body definitions")
|
||||
|
||||
@@ -227,28 +232,27 @@ def main():
|
||||
parser.add_argument("--chrome", action="store_true",
|
||||
help="Also render heightmap with title/legend (review only, not shipped)")
|
||||
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Parse heightmap size
|
||||
try:
|
||||
hw, hh = args.heightmap_size.lower().split("x")
|
||||
hmap_w, hmap_h = int(hw), int(hh)
|
||||
except ValueError:
|
||||
print(f"error: invalid heightmap size '{args.heightmap_size}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
|
||||
|
||||
# ── Collect body definitions ─────────────────────────────────────────
|
||||
body_defs = []
|
||||
|
||||
if args.system:
|
||||
# Read from system index.md → parse bodies table
|
||||
from body_definition_parser import parse_system
|
||||
from tooling.domains.atlas.planet.body_definition_parser import parse_system
|
||||
overrides = {}
|
||||
if args.overrides:
|
||||
with open(args.overrides) as f:
|
||||
overrides = json.load(f)
|
||||
body_defs = parse_system(args.system, overrides=overrides)
|
||||
print(f"System: {args.system} — {len(body_defs)} bodies")
|
||||
console.event(f"System: {args.system} — {len(body_defs)} bodies")
|
||||
elif args.body_def:
|
||||
input_path = args.body_def
|
||||
if input_path.endswith(".json"):
|
||||
@@ -269,15 +273,11 @@ def main():
|
||||
if args.output_dir == ".":
|
||||
args.output_dir = str(Path(input_path).parent)
|
||||
else:
|
||||
print(f"error: {input_path} frontmatter missing 'id' or 'planet_class'",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"{input_path} frontmatter missing 'id' or 'planet_class'", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
|
||||
else:
|
||||
print(f"error: {input_path} has no YAML frontmatter", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"{input_path} has no YAML frontmatter", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
|
||||
else:
|
||||
print(f"error: unrecognized input format: {input_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"unrecognized input format: {input_path}", fix="pass a body definition .json or a body index.md")
|
||||
else:
|
||||
parser.error("Provide a body_def (.json or .md) or --system index.md")
|
||||
|
||||
@@ -288,7 +288,7 @@ def main():
|
||||
args.render_mode, args.output_dir, chrome=args.chrome)
|
||||
|
||||
elapsed = time.time() - t_total
|
||||
print(f"\n All done: {len(body_defs)} bodies in {elapsed:.1f}s")
|
||||
console.event(f" All done: {len(body_defs)} bodies in {elapsed:.1f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
+24
-22
@@ -18,9 +18,9 @@ only its display file is renamed.
|
||||
No systems.db writes: the PNG is the store (the atlas_body_heightmaps BLOB
|
||||
table is dropped). Run with uv (numpy/scipy/Pillow):
|
||||
|
||||
uv run python tooling/planet-gen/import_heightmaps.py # full bake
|
||||
uv run python tooling/planet-gen/import_heightmaps.py --limit 3 # smoke test
|
||||
uv run python tooling/planet-gen/import_heightmaps.py --dry-run
|
||||
reach atlas planet import-heightmaps # full bake
|
||||
reach atlas planet import-heightmaps --limit 3 # smoke test
|
||||
reach atlas planet import-heightmaps --dry-run
|
||||
|
||||
Exit codes: 0 = completed (possibly with per-body errors), 1 = fatal.
|
||||
"""
|
||||
@@ -32,17 +32,20 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
sys.path.insert(0, str(TOOLING_DIR))
|
||||
PLANET_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = config.repo_root()
|
||||
|
||||
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
|
||||
from tooling.domains.atlas.planet.planet_simulation import simulate # noqa: E402
|
||||
from tooling.domains.atlas.planet.render_heightmap import render_heightmap # noqa: E402
|
||||
|
||||
from body_definition_parser import parse_system # noqa: E402
|
||||
from planet_simulation import simulate # noqa: E402
|
||||
from render_heightmap import render_heightmap # noqa: E402
|
||||
|
||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
|
||||
@@ -89,7 +92,7 @@ def bake_body(bd: dict, body_dir: Path, dry_run: bool) -> dict:
|
||||
return {"status": "baked", "shape": elevation.shape, "sea_level": sea_level}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
ap = argparse.ArgumentParser(description="Bake canonical heightmap/reliefmap assets (#963)")
|
||||
ap.add_argument("--db", default=str(DB_PATH))
|
||||
ap.add_argument("--body", help="Bake only this body_id")
|
||||
@@ -100,17 +103,16 @@ def main() -> None:
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
|
||||
|
||||
print("\n Heightmap bake (#963, D-202 amended)")
|
||||
console.event(" Heightmap bake (#963, D-202 amended)")
|
||||
if args.dry_run:
|
||||
print(" Mode: DRY RUN (no files written)")
|
||||
console.event(" Mode: DRY RUN (no files written)")
|
||||
|
||||
# 1. Universal rename of the legacy color heightmap.png → reliefmap.png.
|
||||
if not args.skip_rename:
|
||||
n_renamed = rename_legacy_heightmaps(args.dry_run)
|
||||
print(f" Renamed legacy heightmap.png → reliefmap.png: {n_renamed} bodies")
|
||||
console.event(f" Renamed legacy heightmap.png → reliefmap.png: {n_renamed} bodies")
|
||||
|
||||
# 2. Bake non-Sol inhabited bodies.
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
@@ -125,7 +127,7 @@ def main() -> None:
|
||||
if args.limit:
|
||||
rows = rows[: args.limit]
|
||||
|
||||
print(f" Baking {len(rows)} non-Sol inhabited bodies\n")
|
||||
console.event(f" Baking {len(rows)} non-Sol inhabited bodies\n")
|
||||
|
||||
parsed: dict[str, list] = {}
|
||||
t0 = time.time()
|
||||
@@ -137,12 +139,12 @@ def main() -> None:
|
||||
defs = parsed.get(sys_index) or parse_system(sys_index)
|
||||
parsed[sys_index] = defs
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
|
||||
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
|
||||
n_err += 1
|
||||
continue
|
||||
bd = next((d for d in defs if d.get("id") == body_id), None)
|
||||
if bd is None:
|
||||
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
|
||||
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
|
||||
n_err += 1
|
||||
continue
|
||||
ts = time.time()
|
||||
@@ -151,16 +153,16 @@ def main() -> None:
|
||||
if res["status"] == "baked":
|
||||
n_baked += 1
|
||||
if (i + 1) % 25 == 0 or args.limit:
|
||||
print(f" [{i+1}/{len(rows)}] {body_id:18s} baked {res['shape']} "
|
||||
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} baked {res['shape']} "
|
||||
f"sea={res['sea_level']:.3f} ({dt:.1f}s)")
|
||||
elif res["status"] == "gas_giant":
|
||||
n_gas += 1
|
||||
else:
|
||||
n_err += 1
|
||||
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
|
||||
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
|
||||
|
||||
print(f"\n Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
|
||||
print(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
|
||||
console.event(f" Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
|
||||
console.event(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
|
||||
if n_err:
|
||||
sys.exit(0) # per-body errors are non-fatal; reported above
|
||||
|
||||
+32
-33
@@ -32,10 +32,10 @@ Incremental: bodies that already have rows in atlas_province_boundaries are skip
|
||||
unless --force is passed.
|
||||
|
||||
Usage:
|
||||
tooling/planet-gen/import_province_boundaries.py
|
||||
tooling/planet-gen/import_province_boundaries.py --body GJ380c
|
||||
tooling/planet-gen/import_province_boundaries.py --force
|
||||
tooling/planet-gen/import_province_boundaries.py --dry-run
|
||||
reach atlas planet import-provinces
|
||||
reach atlas planet import-provinces --body GJ380c
|
||||
reach atlas planet import-provinces --force
|
||||
reach atlas planet import-provinces --dry-run
|
||||
|
||||
Exit codes:
|
||||
0 completed
|
||||
@@ -44,22 +44,25 @@ Exit codes:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
|
||||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
import os
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
PLANET_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = config.repo_root()
|
||||
|
||||
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
|
||||
# script under .venv/bin/python so numpy would resolve when run by path.
|
||||
# reach declares numpy and Pillow itself, so its own environment already has
|
||||
# them — and an os.execv into a different interpreter, carrying reach's
|
||||
# argv, would have relaunched something that is not this command at all.
|
||||
|
||||
import numpy as np
|
||||
import sqlite3
|
||||
|
||||
from atlas_common import (
|
||||
from tooling.domains.atlas.planet.atlas_common import (
|
||||
DB_PATH,
|
||||
ensure_atlas_schema,
|
||||
query_inhabited_bodies,
|
||||
@@ -441,7 +444,7 @@ def import_body_provinces(
|
||||
|
||||
if verbose:
|
||||
areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins]
|
||||
print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
|
||||
console.event(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
|
||||
|
||||
if not dry_run:
|
||||
with conn: # per-body transaction (BEGIN/COMMIT): rolls back this body on exception, keeps prior commits
|
||||
@@ -460,7 +463,7 @@ def import_body_provinces(
|
||||
return {"status": "imported", "imported": len(basins)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pre-compute province boundaries from watershed analysis (D-205, #907)"
|
||||
)
|
||||
@@ -472,20 +475,18 @@ def main() -> None:
|
||||
help="Analyse without writing to DB")
|
||||
parser.add_argument("--verbose", action="store_true",
|
||||
help="Print per-body detail")
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
db_path = Path(args.db)
|
||||
if not db_path.exists():
|
||||
print(f"error: {db_path} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
|
||||
|
||||
print("\n Province Boundary Import (#907)")
|
||||
print(f" DB: {db_path}")
|
||||
console.event(" Province Boundary Import (#907)")
|
||||
console.event(f" DB: {db_path}")
|
||||
if args.dry_run:
|
||||
print(" Mode: DRY RUN (no DB writes)")
|
||||
console.event(" Mode: DRY RUN (no DB writes)")
|
||||
if args.force:
|
||||
print(" Force: enabled (will overwrite existing rows)")
|
||||
print()
|
||||
console.event(" Force: enabled (will overwrite existing rows)")
|
||||
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
@@ -495,12 +496,10 @@ def main() -> None:
|
||||
if args.body:
|
||||
bodies = [b for b in bodies if b["body_id"] == args.body]
|
||||
if not bodies:
|
||||
print(f"error: body '{args.body}' not found or has no terrain_reference",
|
||||
file=sys.stderr)
|
||||
conn.close()
|
||||
sys.exit(1)
|
||||
raise ReachError(f"body '{args.body}' not found or has no terrain_reference", fix="reach atlas planet terrain-reference, then retry")
|
||||
|
||||
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
|
||||
console.event(f" {len(bodies)} inhabited bodies with terrain_reference\n")
|
||||
|
||||
t_total = time.time()
|
||||
n_imported = 0
|
||||
@@ -518,28 +517,28 @@ def main() -> None:
|
||||
|
||||
if status == "imported":
|
||||
n_imported += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
|
||||
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
|
||||
elif status == "skipped":
|
||||
n_skipped += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
|
||||
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
|
||||
elif status == "no_heightmap":
|
||||
n_no_hmap += 1
|
||||
if args.verbose:
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
|
||||
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
|
||||
elif status == "error":
|
||||
n_errors += 1
|
||||
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
|
||||
|
||||
conn.close()
|
||||
|
||||
elapsed_total = time.time() - t_total
|
||||
print(f"\n Done in {elapsed_total:.1f}s")
|
||||
print(f" imported={n_imported} skipped={n_skipped} "
|
||||
console.event(f" Done in {elapsed_total:.1f}s")
|
||||
console.event(f" imported={n_imported} skipped={n_skipped} "
|
||||
f"no_heightmap={n_no_hmap} errors={n_errors}")
|
||||
|
||||
if n_errors > 0:
|
||||
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr)
|
||||
raise ReachError(f"{n_errors} error(s) — check output above", fix="re-run the failing body alone: reach atlas planet import-provinces --body <id> --verbose")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
+5
-4
@@ -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.")
|
||||
+10
-9
@@ -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}/")
|
||||
+27
-25
@@ -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__":
|
||||
+12
-11
@@ -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")
|
||||
@@ -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))
|
||||
+24
-21
@@ -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__":
|
||||
+27
-12
@@ -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()
|
||||
+18
-19
@@ -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]
|
||||
|
||||
+2
-1
@@ -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
|
||||
+7
-9
@@ -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)
|
||||
+7
-9
@@ -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()
|
||||
+11
-13
@@ -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)
|
||||
+12
-11
@@ -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 ────────────────────────────────────────────────────
|
||||
+13
-17
@@ -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)
|
||||
+10
-12
@@ -18,15 +18,15 @@ Properties:
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from scipy.ndimage import gaussian_filter
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_image_as_elevation, resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
)
|
||||
from tooling.core import console
|
||||
|
||||
# Cassini ISS global mosaic
|
||||
TITAN_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/5e5ba96a58d3b38ee6e7b1e94b8c44e6_titan_iss_p19658_mosaic_global_4km.jpg"
|
||||
@@ -40,11 +40,11 @@ def _load_titan_mosaic() -> np.ndarray:
|
||||
"""Load Titan mosaic and convert to synthetic elevation."""
|
||||
try:
|
||||
path = ensure_cached(TITAN_MOSAIC_URL, TITAN_MOSAIC_FILE)
|
||||
print(f" loading Titan mosaic: {path}")
|
||||
console.event(f" loading Titan mosaic: {path}")
|
||||
albedo = load_image_as_elevation(str(path), invert=False)
|
||||
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
|
||||
except Exception as e:
|
||||
print(f" WARNING: Titan mosaic unavailable ({e}), synthetic")
|
||||
console.event(f"Titan mosaic unavailable ({e}), synthetic", level="warn")
|
||||
albedo = _synthetic_titan_terrain()
|
||||
|
||||
# Dark regions = low (lakes/flat), bright = dunes/highlands
|
||||
@@ -66,11 +66,9 @@ def _synthetic_titan_terrain() -> np.ndarray:
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build Titan terrain dict."""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||
|
||||
print(" Titan: loading data...")
|
||||
console.event(" Titan: loading data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
elevation = _load_titan_mosaic()
|
||||
@@ -78,7 +76,7 @@ def build_terrain(body_def: dict) -> dict:
|
||||
# Titan has methane lakes — set sea level to create them
|
||||
# Lakes are concentrated at north polar regions
|
||||
# Use a low sea level so that only the darkest (lowest) areas become liquid
|
||||
from sol_data.shared import compute_sea_level
|
||||
from tooling.domains.atlas.planet.sol_data.shared import compute_sea_level
|
||||
sea_level = compute_sea_level(elevation, TITAN_METHANE_LAKE_FRACTION)
|
||||
surface_water = elevation < sea_level
|
||||
|
||||
@@ -90,7 +88,7 @@ def build_terrain(body_def: dict) -> dict:
|
||||
equatorial_mask = (lat_abs < 0.6)[:, np.newaxis] * np.ones(GRID_W, dtype=bool)
|
||||
surface_water = surface_water & ~equatorial_mask
|
||||
|
||||
print(f" methane lakes: {surface_water.sum()} cells")
|
||||
console.event(f" methane lakes: {surface_water.sum()} cells")
|
||||
|
||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||
# Titan has nearly uniform surface temp due to dense atmosphere + distance
|
||||
@@ -118,7 +116,7 @@ def build_terrain(body_def: dict) -> dict:
|
||||
# Override: methane lakes should be ocean classes, not ice
|
||||
# (The biome function sets ocean depth bands for surface_water, which is
|
||||
# what we want — methane lakes rendered like ocean)
|
||||
print(f" biomes: {len(np.unique(biome))} classes")
|
||||
console.event(f" biomes: {len(np.unique(biome))} classes")
|
||||
|
||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||
hillshade = compute_hillshade(elevation)
|
||||
+14
-18
@@ -14,15 +14,15 @@ Venus properties:
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
from sol_data.download import ensure_cached
|
||||
from sol_data.shared import (
|
||||
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||
GRID_W, GRID_H,
|
||||
load_tiff_as_array, load_raw_binary, resample_to_grid, normalize_01,
|
||||
compute_hillshade, assemble_terrain,
|
||||
temperature_grid_analytical,
|
||||
)
|
||||
from tooling.core import console
|
||||
|
||||
# Magellan topography — USGS GeoTIFF (reliable, PIL-loadable)
|
||||
MAGELLAN_TIFF_URL = "https://planetarymaps.usgs.gov/mosaic/Venus_Magellan_Topography_Global_4641m_v02.tif"
|
||||
@@ -42,56 +42,52 @@ def _load_magellan() -> np.ndarray:
|
||||
# Try USGS GeoTIFF first (reliable, well-defined format)
|
||||
try:
|
||||
path = ensure_cached(MAGELLAN_TIFF_URL, MAGELLAN_TIFF_FILE)
|
||||
print(f" loading Magellan GeoTIFF: {path}")
|
||||
console.event(f" loading Magellan GeoTIFF: {path}")
|
||||
arr = load_tiff_as_array(str(path))
|
||||
# Handle nodata
|
||||
arr[arr < -20000] = 0.0
|
||||
arr[arr > 20000] = 0.0
|
||||
print(f" Magellan shape: {arr.shape}, "
|
||||
console.event(f" Magellan shape: {arr.shape}, "
|
||||
f"range: [{arr.min():.0f}, {arr.max():.0f}] m")
|
||||
return arr
|
||||
except Exception as e:
|
||||
print(f" GeoTIFF failed ({e}), trying PDS binary...")
|
||||
console.event(f" GeoTIFF failed ({e}), trying PDS binary...")
|
||||
|
||||
# PDS fallback — try common dimension/format combinations
|
||||
try:
|
||||
path = ensure_cached(MAGELLAN_PDS_URL, MAGELLAN_PDS_FILE)
|
||||
print(f" loading Magellan PDS: {path}")
|
||||
console.event(f" loading Magellan PDS: {path}")
|
||||
for w, h in [(4096, 2048), (2048, 1024), (8192, 4096)]:
|
||||
try:
|
||||
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
|
||||
arr[arr > 20000] = 0.0
|
||||
arr[arr < -20000] = 0.0
|
||||
print(f" Magellan PDS: {w}x{h}, range: [{arr.min():.0f}, {arr.max():.0f}]")
|
||||
console.event(f" Magellan PDS: {w}x{h}, range: [{arr.min():.0f}, {arr.max():.0f}]")
|
||||
return arr
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception as e3:
|
||||
print(f" PDS also failed ({e3})")
|
||||
console.event(f" PDS also failed ({e3})")
|
||||
|
||||
# All sources failed — fall through to procedural generation
|
||||
print(" WARNING: all Magellan sources failed, using procedural")
|
||||
console.event("all Magellan sources failed, using procedural", level="warn")
|
||||
return None
|
||||
|
||||
|
||||
def build_terrain(body_def: dict) -> dict:
|
||||
"""Build Venus terrain dict from Magellan data."""
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import compute_biome
|
||||
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||
|
||||
print(" Venus: loading Magellan data...")
|
||||
console.event(" Venus: loading Magellan data...")
|
||||
|
||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||
raw = _load_magellan()
|
||||
if raw is None:
|
||||
# Fall back to procedural simulation
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from planet_simulation import simulate
|
||||
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||
return simulate(body_def)
|
||||
|
||||
from sol_data.shared import greenwich_to_dateline
|
||||
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
|
||||
shifted = greenwich_to_dateline(raw)
|
||||
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
|
||||
elevation = normalize_01(elevation_m, VENUS_MIN_ELEV_M, VENUS_MAX_ELEV_M)
|
||||
@@ -7,40 +7,44 @@ markers.json, terrain.npz) by constructing terrain dicts from real
|
||||
planetary science data instead of procedural simulation.
|
||||
|
||||
Usage:
|
||||
python3 sol_import.py # All Sol bodies
|
||||
python3 sol_import.py --body GJ0d # Earth only
|
||||
python3 sol_import.py --body GJ0d --body GJ0e # Earth + Mars
|
||||
python3 sol_import.py --download-only # Fetch data, skip rendering
|
||||
python3 sol_import.py --heightmap-size 2048x1024 --globe-size 1024
|
||||
reach atlas planet sol-import # All Sol bodies
|
||||
reach atlas planet sol-import --body GJ0d # Earth only
|
||||
reach atlas planet sol-import --body GJ0d --body GJ0e # Earth + Mars
|
||||
reach atlas planet sol-import --download-only # Fetch data, skip rendering
|
||||
reach atlas planet sol-import --heightmap-size 2048x1024 --globe-size 1024
|
||||
|
||||
Data is cached in tooling/planet-gen/sol_data/.cache/ after first download.
|
||||
Data is cached in tooling/domains/atlas/planet/sol_data/.cache/ after first download.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
|
||||
from pathlib import Path
|
||||
TOOLING_DIR = Path(__file__).resolve().parent
|
||||
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
||||
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
|
||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
||||
|
||||
from tooling.core import config, console
|
||||
from tooling.core.errors import ReachError
|
||||
PLANET_DIR = Path(__file__).resolve().parent
|
||||
WORKTREE_ROOT = config.repo_root()
|
||||
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
|
||||
# script under .venv/bin/python so numpy would resolve when run by path.
|
||||
# reach declares numpy and Pillow itself, so its own environment already has
|
||||
# them — and an os.execv into a different interpreter, carrying reach's
|
||||
# argv, would have relaunched something that is not this command at all.
|
||||
|
||||
import numpy as np
|
||||
|
||||
from planet_simulation import simulate
|
||||
from render_heightmap import render_heightmap
|
||||
from generate import _build_markers
|
||||
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
|
||||
from tooling.domains.atlas.planet.generate import _build_markers
|
||||
|
||||
|
||||
# Per-body importers (lazy-loaded)
|
||||
SOL_INDEX = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "index.md"
|
||||
SOL_OVERRIDES = TOOLING_DIR / "sol_overrides.json"
|
||||
SOL_OVERRIDES = PLANET_DIR / "sol_overrides.json"
|
||||
SOL_BODIES_DIR = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
|
||||
SOL_MARKERS_DIR = TOOLING_DIR / "sol_markers"
|
||||
SOL_MARKERS_DIR = PLANET_DIR / "sol_markers"
|
||||
|
||||
# Bodies that use real-world data (keyed by body_id → importer module)
|
||||
REAL_DATA_BODIES = {
|
||||
@@ -67,7 +71,7 @@ SKIP_TYPES = {"asteroid_belt", "oort_cloud"}
|
||||
def _load_importer(module_name: str):
|
||||
"""Lazy-import a sol_data.* module."""
|
||||
import importlib
|
||||
return importlib.import_module(f"sol_data.{module_name}")
|
||||
return importlib.import_module(f"tooling.domains.atlas.planet.sol_data.{module_name}")
|
||||
|
||||
|
||||
def _apply_named_features(markers: dict, body_id: str) -> dict:
|
||||
@@ -173,13 +177,13 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
|
||||
# Skip non-renderable types
|
||||
if body_type in SKIP_TYPES:
|
||||
print(f"\n {body_id} ({name}) — skipped ({body_type})")
|
||||
console.event(f" {body_id} ({name}) — skipped ({body_type})")
|
||||
return
|
||||
|
||||
body_dir = output_dir / body_id
|
||||
body_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"\n {body_id} ({name}) — {planet_class}")
|
||||
console.event(f" {body_id} ({name}) — {planet_class}")
|
||||
|
||||
t0 = time.time()
|
||||
|
||||
@@ -190,33 +194,33 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
if is_gas:
|
||||
# Gas giants: no terrain, renderer handles bands procedurally
|
||||
terrain = {}
|
||||
print(" terrain: gas giant (procedural bands)")
|
||||
console.event(" terrain: gas giant (procedural bands)")
|
||||
elif body_id in REAL_DATA_BODIES:
|
||||
# Real-world data import
|
||||
module_name = REAL_DATA_BODIES[body_id]
|
||||
print(f" importing real data via sol_data.{module_name}...")
|
||||
console.event(f" importing real data via sol_data.{module_name}...")
|
||||
importer = _load_importer(module_name)
|
||||
terrain = importer.build_terrain(body_def)
|
||||
if download_only:
|
||||
print(" download complete, skipping render")
|
||||
console.event(" download complete, skipping render")
|
||||
return
|
||||
elif body_id in PROCEDURAL_BODIES:
|
||||
# Fall through to standard procedural simulation
|
||||
print(" procedural simulation (irregular body)...")
|
||||
console.event(" procedural simulation (irregular body)...")
|
||||
terrain = simulate(body_def)
|
||||
else:
|
||||
print(f" WARNING: no importer for {body_id}, using procedural")
|
||||
console.event(f"no importer for {body_id}, using procedural", level="warn")
|
||||
terrain = simulate(body_def)
|
||||
|
||||
t_terrain = time.time()
|
||||
|
||||
if terrain:
|
||||
print(f" terrain: {t_terrain - t0:.1f}s "
|
||||
console.event(f" terrain: {t_terrain - t0:.1f}s "
|
||||
f"sea={terrain['sea_level']:.3f} "
|
||||
f"land={int((~terrain['surface_water']).sum())} "
|
||||
f"rivers={len(terrain['rivers'])}")
|
||||
else:
|
||||
print(f" terrain: gas giant ({t_terrain - t0:.1f}s)")
|
||||
console.event(f" terrain: gas giant ({t_terrain - t0:.1f}s)")
|
||||
|
||||
# ── 2. Render heightmap ─────────────────────────────────────────────
|
||||
t_hmap = t_terrain
|
||||
@@ -226,17 +230,17 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
render_mode=render_mode, chrome=False)
|
||||
hmap_img.save(str(body_dir / "heightmap.png"))
|
||||
t_hmap = time.time()
|
||||
print(f" heightmap: {t_hmap - t_terrain:.1f}s {hmap_w}x{hmap_h}")
|
||||
console.event(f" heightmap: {t_hmap - t_terrain:.1f}s {hmap_w}x{hmap_h}")
|
||||
|
||||
# ── 3. Render globe ─────────────────────────────────────────────────
|
||||
try:
|
||||
from planet_renderer import render_globe
|
||||
from tooling.domains.atlas.planet.planet_renderer import render_globe
|
||||
globe_img = render_globe(body_def, terrain, size=globe_size)
|
||||
globe_img.save(str(body_dir / "globe.png"))
|
||||
t_globe = time.time()
|
||||
print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}x{globe_size}")
|
||||
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}x{globe_size}")
|
||||
except Exception as e:
|
||||
print(f" globe: FAILED — {e}")
|
||||
console.event(f" globe: FAILED — {e}")
|
||||
t_globe = time.time()
|
||||
|
||||
# ── 4. Write data files ─────────────────────────────────────────────
|
||||
@@ -260,7 +264,7 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
||||
_write_index_md(body_def, body_dir)
|
||||
|
||||
elapsed = time.time() - t0
|
||||
print(f" total: {elapsed:.1f}s -> {body_dir}/")
|
||||
console.event(f" total: {elapsed:.1f}s -> {body_dir}/")
|
||||
|
||||
|
||||
def _write_index_md(body_def: dict, body_dir: Path):
|
||||
@@ -298,7 +302,7 @@ def _write_index_md(body_def: dict, body_dir: Path):
|
||||
f.write(md)
|
||||
|
||||
|
||||
def main():
|
||||
def main(argv: list[str] | None = None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Sol system (GJ-0) real-world terrain importer")
|
||||
|
||||
@@ -315,21 +319,19 @@ def main():
|
||||
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
|
||||
default="cartographic")
|
||||
|
||||
args = parser.parse_args()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
# Parse heightmap size
|
||||
try:
|
||||
hw, hh = args.heightmap_size.lower().split("x")
|
||||
hmap_w, hmap_h = int(hw), int(hh)
|
||||
except ValueError:
|
||||
print(f"error: invalid heightmap size '{args.heightmap_size}'",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
|
||||
|
||||
output_dir = Path(args.output_dir) if args.output_dir else SOL_BODIES_DIR
|
||||
|
||||
# Parse body definitions from GJ-0 index.md
|
||||
from body_definition_parser import parse_system
|
||||
from tooling.domains.atlas.planet.body_definition_parser import parse_system
|
||||
|
||||
overrides = {}
|
||||
if SOL_OVERRIDES.exists():
|
||||
@@ -337,15 +339,14 @@ def main():
|
||||
overrides = json.load(f)
|
||||
|
||||
body_defs = parse_system(str(SOL_INDEX), overrides=overrides)
|
||||
print(f"Sol system: {len(body_defs)} bodies parsed")
|
||||
console.event(f"Sol system: {len(body_defs)} bodies parsed")
|
||||
|
||||
# Filter to requested bodies
|
||||
if args.body:
|
||||
requested = set(args.body)
|
||||
body_defs = [bd for bd in body_defs if bd["id"] in requested]
|
||||
if not body_defs:
|
||||
print(f"error: no matching bodies for {args.body}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise ReachError(f"no matching bodies for {args.body}", fix="pass a Sol body id from wiki/star-systems/GJ-0/bodies/ (e.g. GJ0c), or omit --body")
|
||||
|
||||
# Generate
|
||||
t_total = time.time()
|
||||
@@ -356,14 +357,14 @@ def main():
|
||||
args.render_mode, output_dir,
|
||||
download_only=args.download_only)
|
||||
except Exception as e:
|
||||
print(f"\n FAILED: {bd['id']} — {e}")
|
||||
console.event(f" FAILED: {bd['id']} — {e}")
|
||||
failed.append(bd["id"])
|
||||
|
||||
elapsed = time.time() - t_total
|
||||
n_ok = len(body_defs) - len(failed)
|
||||
print(f"\n Done: {n_ok}/{len(body_defs)} bodies in {elapsed:.1f}s")
|
||||
console.event(f" Done: {n_ok}/{len(body_defs)} bodies in {elapsed:.1f}s")
|
||||
if failed:
|
||||
print(f" Failed: {', '.join(failed)}")
|
||||
console.event(f" Failed: {', '.join(failed)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
+17
-12
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -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" "$@"
|
||||
@@ -231,18 +231,26 @@ from tooling.core.command import MARKER
|
||||
from tooling.main import DOMAINS, _load_domain
|
||||
|
||||
report = []
|
||||
for name in sorted(DOMAINS):
|
||||
group = _load_domain(name)
|
||||
ctx = None
|
||||
for verb in group.list_commands(ctx):
|
||||
cmd = group.get_command(ctx, verb)
|
||||
|
||||
def walk(name, group, prefix):
|
||||
# A nested group (`atlas planet`) is not a verb: its callback only keeps it
|
||||
# a group. Recurse into it instead, so the verbs underneath are held to the
|
||||
# contract too -- a one-level walk reported the group and skipped all ten.
|
||||
for verb in group.list_commands(None):
|
||||
cmd = group.get_command(None, verb)
|
||||
if hasattr(cmd, "list_commands"):
|
||||
walk(name, cmd, prefix + verb + " ")
|
||||
continue
|
||||
callback = getattr(cmd, "callback", None)
|
||||
report.append({
|
||||
"domain": name,
|
||||
"verb": verb,
|
||||
"verb": prefix + verb,
|
||||
"decorated": bool(getattr(callback, MARKER, False)),
|
||||
"help": (cmd.help or cmd.short_help or "").strip(),
|
||||
})
|
||||
|
||||
for name in sorted(DOMAINS):
|
||||
walk(name, _load_domain(name), "")
|
||||
print(json.dumps(report))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -6,7 +6,7 @@ The 1024×512 elevation bump (D-202 amended) claims to be deterministic — the
|
||||
means a full regeneration. This asserts that simulating the same body twice
|
||||
yields a bit-identical elevation array.
|
||||
|
||||
Run: uv run python tooling/planet-gen/test_sim_determinism.py
|
||||
Run: .venv/bin/python tooling/test_planet_determinism.py
|
||||
Exit: 0 = deterministic, 1 = drift detected, 2 = could not find a test body.
|
||||
"""
|
||||
import hashlib
|
||||
@@ -14,13 +14,13 @@ import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(REPO / "tooling" / "planet-gen"))
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO))
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
|
||||
from body_definition_parser import parse_system # noqa: E402
|
||||
from planet_simulation import GRID_H, GRID_W, simulate # noqa: E402
|
||||
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
|
||||
from tooling.domains.atlas.planet.planet_simulation import GRID_H, GRID_W, simulate # noqa: E402
|
||||
|
||||
|
||||
def _elev_hash(terrain) -> str:
|
||||
@@ -11,16 +11,16 @@ original authored constants were tuned at. See the module docstring on
|
||||
|
||||
Pure arithmetic (no numpy/scipy dependency) — stdlib `unittest` only. Run
|
||||
directly or via `make test-tooling`:
|
||||
python3 tooling/planet-gen/test_oasis_ring_scaling.py
|
||||
.venv/bin/python tooling/test_planet_oasis_rings.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from planet_simulation import GRID_W, oasis_ring_iterations # noqa: E402
|
||||
from tooling.domains.atlas.planet.planet_simulation import GRID_W, oasis_ring_iterations # noqa: E402
|
||||
|
||||
|
||||
class OasisRingScalingTests(unittest.TestCase):
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user