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`,
|
— imported by both the importer's stamp writer and `check-systems-db-stamp`,
|
||||||
and listed via `python3 tooling/generator_sources.py --list`.
|
and listed via `python3 tooling/generator_sources.py --list`.
|
||||||
|
|
||||||
The surviving planet-gen importers (`import_heightmaps`,
|
The surviving `reach atlas planet` importers (`import-heightmaps`,
|
||||||
`import_province_boundaries`) are one-time build imports baked into the
|
`import-provinces`) are one-time build imports baked into the
|
||||||
committed DB — not part of `make regen-db`, and intentionally not stamped.
|
committed DB — not part of `make regen-db`, and intentionally not stamped.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -200,11 +200,11 @@ session once already.
|
|||||||
| tool | does |
|
| tool | does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `tooling/db/wiki_sync.py` | systems.db → star-system page sections |
|
| `tooling/db/wiki_sync.py` | systems.db → star-system page sections |
|
||||||
| `tooling/planet-gen/scaffold_bodies.py` | creates body dirs/pages |
|
| `reach atlas planet scaffold` | creates body dirs/pages |
|
||||||
| `tooling/planet-gen/body_definition_parser.py` | reads body frontmatter |
|
| `tooling/domains/atlas/planet/body_definition_parser.py` | reads body frontmatter |
|
||||||
| `tooling/db/populate_gttr_hook.py` | GTTR prose → `gttr_hook` |
|
| `tooling/db/populate_gttr_hook.py` | GTTR prose → `gttr_hook` |
|
||||||
| `tooling/planet-gen/populate_terrain_reference.py` | terrain asset paths |
|
| `reach atlas planet terrain-reference` | terrain asset paths |
|
||||||
| `tooling/planet-gen/atlas_cohesion_audit.py` | audits atlas coherence |
|
| `reach atlas planet audit` | audits atlas coherence |
|
||||||
| `make regen-db` | economics TOML + corp frontmatter → systems.db |
|
| `make regen-db` | economics TOML + corp frontmatter → systems.db |
|
||||||
|
|
||||||
`pql` queries the vault: `pql search`, `pql backlinks`, `pql related`,
|
`pql` queries the vault: `pql search`, `pql backlinks`, `pql related`,
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Tooling test gate (T-1066) ---
|
# --- 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
|
# --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.
|
# push actually touches tooling/ (or pyproject.toml), same scope as ruff above.
|
||||||
if [ "$TOOLING_CHANGED" -eq 0 ]; then
|
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),
|
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;
|
`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 ('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 ('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 ('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 ('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),
|
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;
|
`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)
|
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.
|
# 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).
|
# be bit-identical (guards the expensive 271-body heightmap bake).
|
||||||
# Exit 2 = no testable body found — warn, don't block.
|
# Exit 2 = no testable body found — warn, don't block.
|
||||||
# 2. import_economics --dry-run against the committed DB: full parse +
|
# 2. import_economics --dry-run against the committed DB: full parse +
|
||||||
# structural/coverage validation, no writes. Exit 2 = coverage-gate
|
# structural/coverage validation, no writes. Exit 2 = coverage-gate
|
||||||
# warning (D-175) — tolerated, matching regen-db's treatment.
|
# warning (D-175) — tolerated, matching regen-db's treatment.
|
||||||
test-tooling:
|
test-tooling:
|
||||||
@echo " [test-tooling] planet-gen determinism guard (#963)..."
|
@echo " [test-tooling] atlas planet determinism guard (#963)..."
|
||||||
@rc=0; $(VENV_PY) tooling/planet-gen/test_sim_determinism.py || rc=$$?; \
|
@rc=0; $(VENV_PY) tooling/test_planet_determinism.py || rc=$$?; \
|
||||||
if [ $$rc -eq 2 ]; then \
|
if [ $$rc -eq 2 ]; then \
|
||||||
echo " WARNING: determinism guard found no testable body (exit 2) — not blocking"; \
|
echo " WARNING: determinism guard found no testable body (exit 2) — not blocking"; \
|
||||||
elif [ $$rc -ne 0 ]; then \
|
elif [ $$rc -ne 0 ]; then \
|
||||||
echo " FAIL: planet_simulation determinism drift (exit $$rc)"; exit $$rc; \
|
echo " FAIL: planet_simulation determinism drift (exit $$rc)"; exit $$rc; \
|
||||||
fi
|
fi
|
||||||
@echo " [test-tooling] oasis ring-scaling pin (T-964, PR #210 review)..."
|
@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 " 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)..."
|
@echo " [test-tooling] canvas-generation version gate units (T-1242)..."
|
||||||
@mkdir -p .cache
|
@mkdir -p .cache
|
||||||
@python3 tooling/test_canvas_version_check.py 2> .cache/test-tooling-canvas-version.log || \
|
@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 # Run all tests (test-server + test-client)
|
||||||
make test-server # Rust tests via tests/run-rust (cargo nextest, JSON summary)
|
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-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:
|
The IPC test layers (D-030) have dedicated targets:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Atlas Hand-Refine Log — Sprint 36
|
# Atlas Hand-Refine Log — Sprint 36
|
||||||
|
|
||||||
Tracks all hand-edits to markers.json files above the batch Gemma 2 pass (#833).
|
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)
|
## Sprint 36 — Core-World Cohesion Pass (#849)
|
||||||
|
|
||||||
**Analysis date:** 2026-04-19
|
**Analysis date:** 2026-04-19
|
||||||
**Analysis script:** `tooling/planet-gen/atlas_quality_analysis.py`
|
**Analysis script:** `reach atlas planet quality`
|
||||||
|
|
||||||
### Key findings from analysis
|
### 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.
|
- **"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.
|
- **"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` |
|
| `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` |
|
| `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 | — |
|
| ~~`starmap`~~ | **folded into `atlas map`** — the top rung of the same ladder | — |
|
||||||
| ~~`planet`~~ | **folded into `atlas planet`** — the third 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` |
|
| `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
|
6. Earth-echo concentration in a given system
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 tooling/planet-gen/atlas_cohesion_audit.py
|
reach atlas planet audit
|
||||||
python3 tooling/planet-gen/atlas_cohesion_audit.py --system GJ 144
|
reach atlas planet audit --system GJ 144
|
||||||
python3 tooling/planet-gen/atlas_cohesion_audit.py --body GJ144d
|
reach atlas planet audit --body GJ144d
|
||||||
|
|
||||||
Decisions: D-191 (atlas pipeline, corridor palettes, markers.json format)
|
Decisions: D-191 (atlas pipeline, corridor palettes, markers.json format)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
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"
|
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||||
|
|
||||||
CARDINALS = ("North", "South", "East", "West", "Northern", "Southern",
|
CARDINALS = ("North", "South", "East", "West", "Northern", "Southern",
|
||||||
@@ -59,9 +62,9 @@ def get_conn(db_path: Path) -> sqlite3.Connection:
|
|||||||
|
|
||||||
|
|
||||||
def section(title: str) -> None:
|
def section(title: str) -> None:
|
||||||
print(f"\n{'='*70}")
|
console.out(f"\n{'='*70}")
|
||||||
print(f" {title}")
|
console.out(f" {title}")
|
||||||
print('='*70)
|
console.out('='*70)
|
||||||
|
|
||||||
|
|
||||||
def run_all_features_for_body(conn, body_id: str) -> list[tuple[str, str]]:
|
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
|
f"AND (name = '' OR LENGTH(name) < 2)", params
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for r in rows:
|
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
|
found += 1
|
||||||
if not found:
|
if not found:
|
||||||
print(" None found.")
|
console.out(" None found.")
|
||||||
|
|
||||||
|
|
||||||
def report_generic_lazy(conn, system_id: str | None, body_id: str | None) -> None:
|
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)
|
hits[key] = (r['body_id'], r['local_id'], r['name'], label)
|
||||||
|
|
||||||
for key, (bid, lid, name, lbl) in sorted(hits.items()):
|
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
|
found += 1
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
print(" None found.")
|
console.out(" None found.")
|
||||||
|
|
||||||
|
|
||||||
def report_cardinals(conn, system_id: str | None, body_id: str | None) -> None:
|
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:
|
if not by_body:
|
||||||
print(" None found.")
|
console.out(" None found.")
|
||||||
return
|
return
|
||||||
for bid, entries in sorted(by_body.items()):
|
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:
|
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:
|
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:
|
if not by_body:
|
||||||
print(" None found.")
|
console.out(" None found.")
|
||||||
return
|
return
|
||||||
for bid, entries in sorted(by_body.items()):
|
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:
|
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:
|
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:
|
if len(items) > 1:
|
||||||
types = set(i[0] for i in items)
|
types = set(i[0] for i in items)
|
||||||
if len(types) > 1: # only flag cross-feature (different types)
|
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:
|
for lbl, nm in items:
|
||||||
print(f" [{lbl}] '{nm}'")
|
console.out(f" [{lbl}] '{nm}'")
|
||||||
found += 1
|
found += 1
|
||||||
if not found:
|
if not found:
|
||||||
print(" None found.")
|
console.out(" None found.")
|
||||||
|
|
||||||
|
|
||||||
def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
|
def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
|
||||||
section("CROSS-BODY STEM DUPLICATES WITHIN SYSTEM (same corridor)")
|
section("CROSS-BODY STEM DUPLICATES WITHIN SYSTEM (same corridor)")
|
||||||
if not system_id:
|
if not system_id:
|
||||||
print(" (requires --system; skipped)")
|
console.out(" (requires --system; skipped)")
|
||||||
return
|
return
|
||||||
|
|
||||||
bodies_q = conn.execute(
|
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()):
|
for stem, hits in sorted(all_names.items()):
|
||||||
bodies_hit = set(h[0] for h in hits)
|
bodies_hit = set(h[0] for h in hits)
|
||||||
if len(bodies_hit) > 1:
|
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:
|
for bid, label, name in hits:
|
||||||
print(f" {bid} [{label}] '{name}'")
|
console.out(f" {bid} [{label}] '{name}'")
|
||||||
found += 1
|
found += 1
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
print(" None found.")
|
console.out(" None found.")
|
||||||
|
|
||||||
|
|
||||||
def report_summary_score(conn, system_id: str | None, body_id: str | None) -> None:
|
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
|
f"SELECT body_id, proper_name, population FROM bodies {where}", params
|
||||||
).fetchall()
|
).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:
|
for row in bodies_q:
|
||||||
bid = row["body_id"]
|
bid = row["body_id"]
|
||||||
name = row["proper_name"] or ""
|
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]
|
).fetchone()[0]
|
||||||
counts[key] = n
|
counts[key] = n
|
||||||
|
|
||||||
print(
|
console.out(f" {bid:<20} {name:<20} {pop:<12,} "
|
||||||
f" {bid:<20} {name:<20} {pop:<12,} "
|
|
||||||
f"{counts['c']:>5} {counts['r']:>6} {counts['o']:>6} "
|
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 = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--system", help="GJ catalog ID (e.g. 'GJ 144')")
|
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("--body", help="Body ID (e.g. 'GJ144d')")
|
||||||
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
|
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)
|
db = Path(args.db)
|
||||||
if not db.exists():
|
if not db.exists():
|
||||||
print(f"error: database not found at {db}", file=sys.stderr)
|
raise ReachError(f"database not found at {db}", fix="make regen-db, or point --db at an existing systems.db")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
conn = get_conn(db)
|
conn = get_conn(db)
|
||||||
|
|
||||||
@@ -364,12 +364,12 @@ def main() -> None:
|
|||||||
if row:
|
if row:
|
||||||
system_id = row["system_id"]
|
system_id = row["system_id"]
|
||||||
|
|
||||||
print("\nAtlas Cohesion Audit")
|
console.out("\nAtlas Cohesion Audit")
|
||||||
print(f" DB: {db}")
|
console.out(f" DB: {db}")
|
||||||
if system_id:
|
if system_id:
|
||||||
print(f" System: {system_id}")
|
console.out(f" System: {system_id}")
|
||||||
if body_id:
|
if body_id:
|
||||||
print(f" Body: {body_id}")
|
console.out(f" Body: {body_id}")
|
||||||
|
|
||||||
report_summary_score(conn, system_id, body_id)
|
report_summary_score(conn, system_id, body_id)
|
||||||
report_empty_names(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)
|
report_cross_body_stem_dupes(conn, system_id)
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
print("\nDone.\n")
|
console.out("\nDone.\n")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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/
|
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
|
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
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tooling.core import config
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Paths and grid constants
|
# Paths and grid constants
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
TOOLING_DIR = Path(__file__).resolve().parent
|
PLANET_DIR = Path(__file__).resolve().parent
|
||||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
REPO_ROOT = config.repo_root()
|
||||||
|
|
||||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||||
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
|
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
|
5. Top-stem frequency across all named features
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 tooling/planet-gen/atlas_quality_analysis.py [--db server/data/systems.db]
|
reach atlas planet quality [--db server/data/systems.db]
|
||||||
python3 tooling/planet-gen/atlas_quality_analysis.py --system GJ380
|
reach atlas planet quality --system GJ380
|
||||||
python3 tooling/planet-gen/atlas_quality_analysis.py --top-collisions 20
|
reach atlas planet quality --top-collisions 20
|
||||||
python3 tooling/planet-gen/atlas_quality_analysis.py --body GJ71c
|
reach atlas planet quality --body GJ71c
|
||||||
|
|
||||||
D-191 §8: markers.json is pixel-space [row, col] against 512×256.
|
D-191 §8: markers.json is pixel-space [row, col] against 512×256.
|
||||||
Re-run after any hand-refine pass to verify improvements.
|
Re-run after any hand-refine pass to verify improvements.
|
||||||
@@ -23,9 +23,11 @@ import argparse
|
|||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections import Counter, defaultdict
|
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"
|
DEFAULT_DB = REPO_ROOT / "server" / "data" / "systems.db"
|
||||||
|
|
||||||
CARDINAL_RE = re.compile(
|
CARDINAL_RE = re.compile(
|
||||||
@@ -154,26 +156,26 @@ def run_analysis(args):
|
|||||||
if args.body:
|
if args.body:
|
||||||
body_index = {k: v for k, v in body_index.items() if k == args.body}
|
body_index = {k: v for k, v in body_index.items() if k == args.body}
|
||||||
|
|
||||||
print("=" * 70)
|
console.out("=" * 70)
|
||||||
print("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
|
console.out("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
|
||||||
print(f"DB: {args.db}")
|
console.out(f"DB: {args.db}")
|
||||||
if args.system:
|
if args.system:
|
||||||
print(f"Filter: system={args.system}")
|
console.out(f"Filter: system={args.system}")
|
||||||
if args.body:
|
if args.body:
|
||||||
print(f"Filter: body={args.body}")
|
console.out(f"Filter: body={args.body}")
|
||||||
print("=" * 70)
|
console.out("=" * 70)
|
||||||
|
|
||||||
# --- 1. Cross-body collisions ---
|
# --- 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)
|
collisions = cross_body_collisions(conn, limit=args.top_collisions)
|
||||||
for feat_type, rows in collisions.items():
|
for feat_type, rows in collisions.items():
|
||||||
if rows:
|
if rows:
|
||||||
print(f"\n {feat_type}:")
|
console.out(f"\n {feat_type}:")
|
||||||
for name, cnt, bodies in rows:
|
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 ---
|
# --- 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 = []
|
reports = []
|
||||||
for bid, info in body_index.items():
|
for bid, info in body_index.items():
|
||||||
names = all_names_by_body.get(bid, [])
|
names = all_names_by_body.get(bid, [])
|
||||||
@@ -186,27 +188,25 @@ def run_analysis(args):
|
|||||||
|
|
||||||
reports.sort(key=lambda x: -x[2]["colliding_pct"])
|
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}")
|
f"{'Coll%':>6} {'Card%':>6} {'Gen':>4} {'Echo':>4}")
|
||||||
for bid, info, rep in reports[:30]:
|
for bid, info, rep in reports[:30]:
|
||||||
print(
|
console.out(f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
|
||||||
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['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 ---
|
# --- 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]
|
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):
|
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) ---
|
# --- 4. Detailed body report (if --body specified) ---
|
||||||
if args.body and args.body in all_names_by_body:
|
if args.body and args.body in all_names_by_body:
|
||||||
bid = args.body
|
bid = args.body
|
||||||
info = body_index.get(bid, {})
|
info = body_index.get(bid, {})
|
||||||
names = all_names_by_body[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()
|
c = conn.cursor()
|
||||||
for feat_type, name, local_id in sorted(names, key=lambda x: x[0]):
|
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]
|
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 ""
|
flag = f" *** COLLISION ×{others}" if others > 0 else ""
|
||||||
cardinal = " [cardinal]" if CARDINAL_RE.search(name) else ""
|
cardinal = " [cardinal]" if CARDINAL_RE.search(name) else ""
|
||||||
generic = " [generic]" if GENERIC_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 ---
|
# --- 5. Sol gap check ---
|
||||||
print("\n[ 5. SOL SYSTEM GAP CHECK ]")
|
console.out("\n[ 5. SOL SYSTEM GAP CHECK ]")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
c.execute("SELECT body_id, proper_name, population FROM bodies WHERE system_id='GJ 0' AND inhabited=1")
|
c.execute("SELECT body_id, proper_name, population FROM bodies WHERE system_id='GJ 0' AND inhabited=1")
|
||||||
sol_bodies = c.fetchall()
|
sol_bodies = c.fetchall()
|
||||||
for bid, bname, pop in sol_bodies:
|
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])
|
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"
|
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()
|
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 = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
parser.add_argument("--db", default=str(DEFAULT_DB), help="Path to systems.db")
|
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("--system", help="Filter to one system (e.g. GJ380)")
|
||||||
parser.add_argument("--body", help="Filter to one body (e.g. GJ71c)")
|
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")
|
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)
|
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.
|
then generates heightmap + globe + terrain data for every body.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 batch.py # full run: scaffold + generate
|
reach atlas planet batch # full run: scaffold + generate
|
||||||
python3 batch.py --scaffold-only # just create body index.md files
|
reach atlas planet batch --scaffold-only # just create body index.md files
|
||||||
python3 batch.py --generate-only # just render (bodies must exist)
|
reach atlas planet batch --generate-only # just render (bodies must exist)
|
||||||
python3 batch.py --system GJ-144 # single system
|
reach atlas planet batch --system GJ-144 # single system
|
||||||
python3 batch.py --system GJ-144 --body GJ144d # single body
|
reach atlas planet batch --system GJ-144 --body GJ144d # single body
|
||||||
python3 batch.py --overrides sol.json # per-body overrides
|
reach atlas planet batch --overrides sol.json # per-body overrides
|
||||||
python3 batch.py --dry-run # validate data, don't generate
|
reach atlas planet batch --dry-run # validate data, don't generate
|
||||||
|
|
||||||
Skips:
|
Skips:
|
||||||
- GJ-0 (Sol) — manual overrides required, use --system GJ-0 explicitly
|
- GJ-0 (Sol) — manual overrides required, use --system GJ-0 explicitly
|
||||||
@@ -26,26 +26,31 @@ Error handling:
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tooling.core import config, console
|
||||||
|
from tooling.core.errors import ReachError
|
||||||
|
|
||||||
# Venv bootstrap
|
# Venv bootstrap
|
||||||
TOOLING_DIR = Path(__file__).resolve().parent
|
PLANET_DIR = Path(__file__).resolve().parent
|
||||||
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
WORKTREE_ROOT = config.repo_root()
|
||||||
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
|
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
|
||||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
# script under .venv/bin/python so numpy would resolve when run by path.
|
||||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
# 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 yaml
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from body_definition_parser import parse_system
|
from tooling.domains.atlas.planet.body_definition_parser import parse_system
|
||||||
from planet_simulation import simulate
|
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||||
from render_heightmap import render_heightmap
|
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
|
||||||
|
|
||||||
|
|
||||||
# Systems to skip in batch mode (require manual handling)
|
# Systems to skip in batch mode (require manual handling)
|
||||||
SKIP_SYSTEMS = {"GJ-0"}
|
SKIP_SYSTEMS = {"GJ-0"}
|
||||||
@@ -165,7 +170,7 @@ def _scaffold_system(system_dir: Path, overrides: dict) -> list:
|
|||||||
if not index_md.exists():
|
if not index_md.exists():
|
||||||
return []
|
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)
|
body_defs = parse_system(str(index_md), overrides=overrides)
|
||||||
bodies_dir = system_dir / "bodies"
|
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")
|
_save_img(hmap_img, body_dir / "heightmap.png")
|
||||||
|
|
||||||
# Globe — atomic write
|
# 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)
|
globe_img = render_globe(bd, terrain, size=globe_size)
|
||||||
_save_img(globe_img, body_dir / "globe.png")
|
_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)
|
np.savez_compressed(str(body_dir / "terrain.npz"), **save_dict)
|
||||||
|
|
||||||
# Markers
|
# Markers
|
||||||
from generate import _build_markers
|
from tooling.domains.atlas.planet.generate import _build_markers
|
||||||
markers = _build_markers(bd, terrain)
|
markers = _build_markers(bd, terrain)
|
||||||
_save(markers, body_dir / "markers.json",
|
_save(markers, body_dir / "markers.json",
|
||||||
lambda m, p: Path(p).write_text(json.dumps(m, indent=2)))
|
lambda m, p: Path(p).write_text(json.dumps(m, indent=2)))
|
||||||
|
|
||||||
elapsed = time.time() - t0
|
elapsed = time.time() - t0
|
||||||
kind = "gas" if is_gas else f"land={int((~terrain['surface_water']).sum())}"
|
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"
|
return "generated"
|
||||||
|
|
||||||
|
|
||||||
@@ -277,7 +282,7 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
|
|||||||
# Main
|
# Main
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main():
|
def main(argv: list[str] | None = None):
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Batch planet generation — all systems unattended")
|
description="Batch planet generation — all systems unattended")
|
||||||
parser.add_argument("--system", help="Process only this system (dir name, e.g. GJ-144)")
|
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")
|
help="Run N random bodies twice and verify identical output")
|
||||||
parser.add_argument("--heightmap-size", default="1024x512")
|
parser.add_argument("--heightmap-size", default="1024x512")
|
||||||
parser.add_argument("--globe-size", type=int, default=512)
|
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")
|
hw, hh = args.heightmap_size.lower().split("x")
|
||||||
hmap_w, hmap_h = int(hw), int(hh)
|
hmap_w, hmap_h = int(hw), int(hh)
|
||||||
|
|
||||||
wiki_systems = WORKTREE_ROOT / "wiki" / "star-systems"
|
wiki_systems = WORKTREE_ROOT / "wiki" / "star-systems"
|
||||||
if not wiki_systems.exists():
|
if not wiki_systems.exists():
|
||||||
print(f"error: {wiki_systems} not found", file=sys.stderr)
|
raise ReachError(f"{wiki_systems} not found", fix="run from a settled-reach checkout — make reach-repoint")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
overrides = {}
|
overrides = {}
|
||||||
if args.overrides:
|
if args.overrides:
|
||||||
@@ -312,8 +316,7 @@ def main():
|
|||||||
if args.system:
|
if args.system:
|
||||||
system_dirs = [wiki_systems / args.system]
|
system_dirs = [wiki_systems / args.system]
|
||||||
if not system_dirs[0].exists():
|
if not system_dirs[0].exists():
|
||||||
print(f"error: system {args.system} not found", file=sys.stderr)
|
raise ReachError(f"system {args.system} not found", fix="--system takes the wiki directory name, e.g. GJ-1002 (see wiki/star-systems/)")
|
||||||
sys.exit(1)
|
|
||||||
else:
|
else:
|
||||||
system_dirs = sorted([
|
system_dirs = sorted([
|
||||||
d for d in wiki_systems.iterdir()
|
d for d in wiki_systems.iterdir()
|
||||||
@@ -334,19 +337,18 @@ def main():
|
|||||||
total_invalid = 0
|
total_invalid = 0
|
||||||
error_rate_threshold = 0.50
|
error_rate_threshold = 0.50
|
||||||
|
|
||||||
print("\n Planet Generator — Batch Mode")
|
console.event(" Planet Generator — Batch Mode")
|
||||||
print(f" Systems: {len(system_dirs)}")
|
console.event(f" Systems: {len(system_dirs)}")
|
||||||
print(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
|
console.event(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(" Mode: DRY RUN (validation only)")
|
console.event(" Mode: DRY RUN (validation only)")
|
||||||
print()
|
|
||||||
|
|
||||||
for system_dir in system_dirs:
|
for system_dir in system_dirs:
|
||||||
system_id = system_dir.name
|
system_id = system_dir.name
|
||||||
|
|
||||||
# Skip Sol in batch mode (needs manual overrides)
|
# Skip Sol in batch mode (needs manual overrides)
|
||||||
if system_id in SKIP_SYSTEMS and not args.system:
|
if system_id in SKIP_SYSTEMS and not args.system:
|
||||||
print(f" {system_id} — skipped (manual)")
|
console.event(f" {system_id} — skipped (manual)")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
system_name = _read_system_name(system_dir / "index.md")
|
system_name = _read_system_name(system_dir / "index.md")
|
||||||
@@ -364,7 +366,7 @@ def main():
|
|||||||
except Exception:
|
except Exception:
|
||||||
n_bodies = 0
|
n_bodies = 0
|
||||||
|
|
||||||
print(f" {system_id} — {system_name} ({n_bodies} bodies)")
|
console.event(f" {system_id} — {system_name} ({n_bodies} bodies)")
|
||||||
|
|
||||||
# ── Scaffold ─────────────────────────────────────────────────────
|
# ── Scaffold ─────────────────────────────────────────────────────
|
||||||
if not args.generate_only and not args.dry_run:
|
if not args.generate_only and not args.dry_run:
|
||||||
@@ -373,10 +375,10 @@ def main():
|
|||||||
if created:
|
if created:
|
||||||
total_scaffolded += len(created)
|
total_scaffolded += len(created)
|
||||||
for bid in created:
|
for bid in created:
|
||||||
print(f" scaffolded {bid}")
|
console.event(f" scaffolded {bid}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tb = traceback.format_exc()
|
tb = traceback.format_exc()
|
||||||
print(f" SCAFFOLD ERROR: {e}")
|
console.event(f" SCAFFOLD ERROR: {e}")
|
||||||
_log_error(system_id, "*", str(e), tb)
|
_log_error(system_id, "*", str(e), tb)
|
||||||
|
|
||||||
# ── Validate / Generate ──────────────────────────────────────────
|
# ── Validate / Generate ──────────────────────────────────────────
|
||||||
@@ -412,12 +414,12 @@ def main():
|
|||||||
errors = _validate_body_def(bd, body_dir)
|
errors = _validate_body_def(bd, body_dir)
|
||||||
if errors:
|
if errors:
|
||||||
total_invalid += 1
|
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:
|
for err in errors:
|
||||||
print(f" - {err}")
|
console.event(f" - {err}")
|
||||||
else:
|
else:
|
||||||
total_valid += 1
|
total_valid += 1
|
||||||
print(f" {body_id:20s} ({body_name:20s}) ok")
|
console.event(f" {body_id:20s} ({body_name:20s}) ok")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if args.scaffold_only:
|
if args.scaffold_only:
|
||||||
@@ -437,30 +439,30 @@ def main():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
total_errors += 1
|
total_errors += 1
|
||||||
tb = traceback.format_exc()
|
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)
|
_log_error(system_id, body_id, str(e), tb)
|
||||||
|
|
||||||
# Circuit breaker: abort if error rate is too high
|
# Circuit breaker: abort if error rate is too high
|
||||||
if total_attempted >= 10 and total_errors / total_attempted > error_rate_threshold:
|
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"({total_errors/total_attempted*100:.0f}%) exceeds "
|
||||||
f"{error_rate_threshold*100:.0f}% threshold")
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
elapsed = time.time() - t_total
|
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:
|
if args.dry_run:
|
||||||
print(f" valid: {total_valid}")
|
console.event(f" valid: {total_valid}")
|
||||||
print(f" invalid: {total_invalid}")
|
console.event(f" invalid: {total_invalid}")
|
||||||
else:
|
else:
|
||||||
print(f" scaffolded: {total_scaffolded}")
|
console.event(f" scaffolded: {total_scaffolded}")
|
||||||
print(f" generated: {total_generated}")
|
console.event(f" generated: {total_generated}")
|
||||||
print(f" skipped: {total_skipped}")
|
console.event(f" skipped: {total_skipped}")
|
||||||
print(f" errors: {total_errors}")
|
console.event(f" errors: {total_errors}")
|
||||||
if total_errors > 0:
|
if total_errors > 0:
|
||||||
print(f" error log: {LOG_PATH}")
|
console.event(f" error log: {LOG_PATH}")
|
||||||
|
|
||||||
# ── Determinism verification ─────────────────────────────────────────
|
# ── Determinism verification ─────────────────────────────────────────
|
||||||
if args.verify_determinism > 0:
|
if args.verify_determinism > 0:
|
||||||
@@ -475,7 +477,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
|||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
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
|
# Collect all body dirs that have been generated
|
||||||
all_body_dirs = []
|
all_body_dirs = []
|
||||||
@@ -487,7 +489,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
|||||||
all_body_dirs.append(bd)
|
all_body_dirs.append(bd)
|
||||||
|
|
||||||
if not all_body_dirs:
|
if not all_body_dirs:
|
||||||
print(" no generated bodies to verify")
|
console.event(" no generated bodies to verify")
|
||||||
return
|
return
|
||||||
|
|
||||||
rng = np.random.default_rng(42)
|
rng = np.random.default_rng(42)
|
||||||
@@ -514,7 +516,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
|||||||
try:
|
try:
|
||||||
_generate_body_from_dir(tmp_body, hmap_w, hmap_h, globe_size, force=True)
|
_generate_body_from_dir(tmp_body, hmap_w, hmap_h, globe_size, force=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" {body_id}: generation failed — {e}")
|
console.event(f" {body_id}: generation failed — {e}")
|
||||||
shutil.rmtree(tmp_dir)
|
shutil.rmtree(tmp_dir)
|
||||||
failed += 1
|
failed += 1
|
||||||
continue
|
continue
|
||||||
@@ -527,13 +529,13 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
|||||||
if not orig.exists() and not rerun.exists():
|
if not orig.exists() and not rerun.exists():
|
||||||
continue
|
continue
|
||||||
if not orig.exists() or not rerun.exists():
|
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
|
all_match = False
|
||||||
continue
|
continue
|
||||||
h1 = hashlib.sha256(orig.read_bytes()).hexdigest()[:16]
|
h1 = hashlib.sha256(orig.read_bytes()).hexdigest()[:16]
|
||||||
h2 = hashlib.sha256(rerun.read_bytes()).hexdigest()[:16]
|
h2 = hashlib.sha256(rerun.read_bytes()).hexdigest()[:16]
|
||||||
if h1 != h2:
|
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
|
all_match = False
|
||||||
|
|
||||||
if all_match:
|
if all_match:
|
||||||
@@ -543,9 +545,14 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
|
|||||||
|
|
||||||
shutil.rmtree(tmp_dir)
|
shutil.rmtree(tmp_dir)
|
||||||
|
|
||||||
print(f" passed: {passed} failed: {failed}")
|
console.event(f" passed: {passed} failed: {failed}")
|
||||||
if failed > 0:
|
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__":
|
if __name__ == "__main__":
|
||||||
@@ -6,7 +6,7 @@ parameters. All three pipeline modules import from here instead of
|
|||||||
maintaining their own hardcoded tables.
|
maintaining their own hardcoded tables.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from biome_config import (
|
from tooling.domains.atlas.planet.biome_config import (
|
||||||
WHITTAKER_TABLE, CLASS_T_BAND, BIOME_PALETTE,
|
WHITTAKER_TABLE, CLASS_T_BAND, BIOME_PALETTE,
|
||||||
STAR_TINTS, ATMO_COLORS, GAS_PALETTES,
|
STAR_TINTS, ATMO_COLORS, GAS_PALETTES,
|
||||||
EXOTIC_CLASSES, CRATER_SCALING, RIVER_RGB, COAST_RGB,
|
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)
|
5. randomised (seeded, within planet-class constraints)
|
||||||
|
|
||||||
Usage:
|
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)
|
# 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:
|
Override file format:
|
||||||
{
|
{
|
||||||
@@ -206,7 +206,8 @@ CLASS_OBLATENESS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Gas giant band palettes available
|
# 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
|
# planet_class → cloud coverage base range
|
||||||
CLASS_CLOUD = {
|
CLASS_CLOUD = {
|
||||||
@@ -855,4 +856,4 @@ if __name__ == "__main__":
|
|||||||
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
|
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
|
||||||
|
|
||||||
if args.print:
|
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 argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
|
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
TOOLING_DIR = Path(__file__).resolve().parent
|
|
||||||
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
from tooling.core import config, console
|
||||||
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
|
from tooling.core.errors import ReachError
|
||||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
PLANET_DIR = Path(__file__).resolve().parent
|
||||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
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
|
import numpy as np
|
||||||
|
|
||||||
from planet_simulation import simulate
|
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||||
from render_heightmap import render_heightmap
|
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _build_markers(body_def: dict, terrain: dict) -> dict:
|
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")
|
planet_class = body_def.get("planet_class", "unknown")
|
||||||
name = body_def.get("name") or body_id
|
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()
|
t0 = time.time()
|
||||||
|
|
||||||
@@ -141,9 +146,9 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
|||||||
t_sim = time.time()
|
t_sim = time.time()
|
||||||
|
|
||||||
if is_gas:
|
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:
|
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"sea={terrain['sea_level']:.3f} "
|
||||||
f"land={int((~terrain['surface_water']).sum())}")
|
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)
|
render_mode=render_mode, chrome=True)
|
||||||
chrome_path = f"/tmp/{body_id}_heightmap_chrome.png"
|
chrome_path = f"/tmp/{body_id}_heightmap_chrome.png"
|
||||||
hmap_chrome.save(chrome_path)
|
hmap_chrome.save(chrome_path)
|
||||||
print(f" chrome: {chrome_path}")
|
console.event(f" chrome: {chrome_path}")
|
||||||
|
|
||||||
t_hmap = time.time()
|
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 ──────────────────────────────────────────────────
|
# ── 3. Render globe ──────────────────────────────────────────────────
|
||||||
try:
|
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 = render_globe(body_def, terrain, size=globe_size)
|
||||||
globe_img.save(os.path.join(body_dir, "globe.png"))
|
globe_img.save(os.path.join(body_dir, "globe.png"))
|
||||||
t_globe = time.time()
|
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:
|
except Exception as e:
|
||||||
print(f" globe: FAILED — {e}")
|
console.event(f" globe: FAILED — {e}")
|
||||||
t_globe = time.time()
|
t_globe = time.time()
|
||||||
|
|
||||||
# ── 4. Write data files ──────────────────────────────────────────────
|
# ── 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)
|
json.dump(markers, f, indent=2)
|
||||||
|
|
||||||
elapsed = time.time() - t0
|
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(
|
parser = argparse.ArgumentParser(
|
||||||
description="Planet generator — heightmap + globe from body definitions")
|
description="Planet generator — heightmap + globe from body definitions")
|
||||||
|
|
||||||
@@ -227,28 +232,27 @@ def main():
|
|||||||
parser.add_argument("--chrome", action="store_true",
|
parser.add_argument("--chrome", action="store_true",
|
||||||
help="Also render heightmap with title/legend (review only, not shipped)")
|
help="Also render heightmap with title/legend (review only, not shipped)")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
# Parse heightmap size
|
# Parse heightmap size
|
||||||
try:
|
try:
|
||||||
hw, hh = args.heightmap_size.lower().split("x")
|
hw, hh = args.heightmap_size.lower().split("x")
|
||||||
hmap_w, hmap_h = int(hw), int(hh)
|
hmap_w, hmap_h = int(hw), int(hh)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print(f"error: invalid heightmap size '{args.heightmap_size}'", file=sys.stderr)
|
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# ── Collect body definitions ─────────────────────────────────────────
|
# ── Collect body definitions ─────────────────────────────────────────
|
||||||
body_defs = []
|
body_defs = []
|
||||||
|
|
||||||
if args.system:
|
if args.system:
|
||||||
# Read from system index.md → parse bodies table
|
# 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 = {}
|
overrides = {}
|
||||||
if args.overrides:
|
if args.overrides:
|
||||||
with open(args.overrides) as f:
|
with open(args.overrides) as f:
|
||||||
overrides = json.load(f)
|
overrides = json.load(f)
|
||||||
body_defs = parse_system(args.system, overrides=overrides)
|
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:
|
elif args.body_def:
|
||||||
input_path = args.body_def
|
input_path = args.body_def
|
||||||
if input_path.endswith(".json"):
|
if input_path.endswith(".json"):
|
||||||
@@ -269,15 +273,11 @@ def main():
|
|||||||
if args.output_dir == ".":
|
if args.output_dir == ".":
|
||||||
args.output_dir = str(Path(input_path).parent)
|
args.output_dir = str(Path(input_path).parent)
|
||||||
else:
|
else:
|
||||||
print(f"error: {input_path} frontmatter missing 'id' or 'planet_class'",
|
raise ReachError(f"{input_path} frontmatter missing 'id' or 'planet_class'", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
|
||||||
file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
else:
|
||||||
print(f"error: {input_path} has no YAML frontmatter", file=sys.stderr)
|
raise ReachError(f"{input_path} has no YAML frontmatter", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
|
||||||
sys.exit(1)
|
|
||||||
else:
|
else:
|
||||||
print(f"error: unrecognized input format: {input_path}", file=sys.stderr)
|
raise ReachError(f"unrecognized input format: {input_path}", fix="pass a body definition .json or a body index.md")
|
||||||
sys.exit(1)
|
|
||||||
else:
|
else:
|
||||||
parser.error("Provide a body_def (.json or .md) or --system index.md")
|
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)
|
args.render_mode, args.output_dir, chrome=args.chrome)
|
||||||
|
|
||||||
elapsed = time.time() - t_total
|
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__":
|
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
|
No systems.db writes: the PNG is the store (the atlas_body_heightmaps BLOB
|
||||||
table is dropped). Run with uv (numpy/scipy/Pillow):
|
table is dropped). Run with uv (numpy/scipy/Pillow):
|
||||||
|
|
||||||
uv run python tooling/planet-gen/import_heightmaps.py # full bake
|
reach atlas planet import-heightmaps # full bake
|
||||||
uv run python tooling/planet-gen/import_heightmaps.py --limit 3 # smoke test
|
reach atlas planet import-heightmaps --limit 3 # smoke test
|
||||||
uv run python tooling/planet-gen/import_heightmaps.py --dry-run
|
reach atlas planet import-heightmaps --dry-run
|
||||||
|
|
||||||
Exit codes: 0 = completed (possibly with per-body errors), 1 = fatal.
|
Exit codes: 0 = completed (possibly with per-body errors), 1 = fatal.
|
||||||
"""
|
"""
|
||||||
@@ -32,17 +32,20 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tooling.core import config, console
|
||||||
|
from tooling.core.errors import ReachError
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from PIL.PngImagePlugin import PngInfo
|
from PIL.PngImagePlugin import PngInfo
|
||||||
|
|
||||||
TOOLING_DIR = Path(__file__).resolve().parent
|
PLANET_DIR = Path(__file__).resolve().parent
|
||||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
REPO_ROOT = config.repo_root()
|
||||||
sys.path.insert(0, str(TOOLING_DIR))
|
|
||||||
|
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"
|
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||||
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
|
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}
|
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 = argparse.ArgumentParser(description="Bake canonical heightmap/reliefmap assets (#963)")
|
||||||
ap.add_argument("--db", default=str(DB_PATH))
|
ap.add_argument("--db", default=str(DB_PATH))
|
||||||
ap.add_argument("--body", help="Bake only this body_id")
|
ap.add_argument("--body", help="Bake only this body_id")
|
||||||
@@ -100,17 +103,16 @@ def main() -> None:
|
|||||||
|
|
||||||
db_path = Path(args.db)
|
db_path = Path(args.db)
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
print(f"error: {db_path} not found", file=sys.stderr)
|
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("\n Heightmap bake (#963, D-202 amended)")
|
console.event(" Heightmap bake (#963, D-202 amended)")
|
||||||
if args.dry_run:
|
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.
|
# 1. Universal rename of the legacy color heightmap.png → reliefmap.png.
|
||||||
if not args.skip_rename:
|
if not args.skip_rename:
|
||||||
n_renamed = rename_legacy_heightmaps(args.dry_run)
|
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.
|
# 2. Bake non-Sol inhabited bodies.
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -125,7 +127,7 @@ def main() -> None:
|
|||||||
if args.limit:
|
if args.limit:
|
||||||
rows = rows[: 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] = {}
|
parsed: dict[str, list] = {}
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
@@ -137,12 +139,12 @@ def main() -> None:
|
|||||||
defs = parsed.get(sys_index) or parse_system(sys_index)
|
defs = parsed.get(sys_index) or parse_system(sys_index)
|
||||||
parsed[sys_index] = defs
|
parsed[sys_index] = defs
|
||||||
except Exception as exc: # noqa: BLE001
|
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
|
n_err += 1
|
||||||
continue
|
continue
|
||||||
bd = next((d for d in defs if d.get("id") == body_id), None)
|
bd = next((d for d in defs if d.get("id") == body_id), None)
|
||||||
if bd is 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
|
n_err += 1
|
||||||
continue
|
continue
|
||||||
ts = time.time()
|
ts = time.time()
|
||||||
@@ -151,16 +153,16 @@ def main() -> None:
|
|||||||
if res["status"] == "baked":
|
if res["status"] == "baked":
|
||||||
n_baked += 1
|
n_baked += 1
|
||||||
if (i + 1) % 25 == 0 or args.limit:
|
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)")
|
f"sea={res['sea_level']:.3f} ({dt:.1f}s)")
|
||||||
elif res["status"] == "gas_giant":
|
elif res["status"] == "gas_giant":
|
||||||
n_gas += 1
|
n_gas += 1
|
||||||
else:
|
else:
|
||||||
n_err += 1
|
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}")
|
console.event(f" 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(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
|
||||||
if n_err:
|
if n_err:
|
||||||
sys.exit(0) # per-body errors are non-fatal; reported above
|
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.
|
unless --force is passed.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
tooling/planet-gen/import_province_boundaries.py
|
reach atlas planet import-provinces
|
||||||
tooling/planet-gen/import_province_boundaries.py --body GJ380c
|
reach atlas planet import-provinces --body GJ380c
|
||||||
tooling/planet-gen/import_province_boundaries.py --force
|
reach atlas planet import-provinces --force
|
||||||
tooling/planet-gen/import_province_boundaries.py --dry-run
|
reach atlas planet import-provinces --dry-run
|
||||||
|
|
||||||
Exit codes:
|
Exit codes:
|
||||||
0 completed
|
0 completed
|
||||||
@@ -44,22 +44,25 @@ Exit codes:
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
TOOLING_DIR = Path(__file__).resolve().parent
|
from tooling.core import config, console
|
||||||
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
from tooling.core.errors import ReachError
|
||||||
|
|
||||||
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
PLANET_DIR = Path(__file__).resolve().parent
|
||||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
REPO_ROOT = config.repo_root()
|
||||||
import os
|
|
||||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
# 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 numpy as np
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from atlas_common import (
|
from tooling.domains.atlas.planet.atlas_common import (
|
||||||
DB_PATH,
|
DB_PATH,
|
||||||
ensure_atlas_schema,
|
ensure_atlas_schema,
|
||||||
query_inhabited_bodies,
|
query_inhabited_bodies,
|
||||||
@@ -441,7 +444,7 @@ def import_body_provinces(
|
|||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins]
|
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:
|
if not dry_run:
|
||||||
with conn: # per-body transaction (BEGIN/COMMIT): rolls back this body on exception, keeps prior commits
|
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)}
|
return {"status": "imported", "imported": len(basins)}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main(argv: list[str] | None = None) -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Pre-compute province boundaries from watershed analysis (D-205, #907)"
|
description="Pre-compute province boundaries from watershed analysis (D-205, #907)"
|
||||||
)
|
)
|
||||||
@@ -472,20 +475,18 @@ def main() -> None:
|
|||||||
help="Analyse without writing to DB")
|
help="Analyse without writing to DB")
|
||||||
parser.add_argument("--verbose", action="store_true",
|
parser.add_argument("--verbose", action="store_true",
|
||||||
help="Print per-body detail")
|
help="Print per-body detail")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
db_path = Path(args.db)
|
db_path = Path(args.db)
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
print(f"error: {db_path} not found", file=sys.stderr)
|
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("\n Province Boundary Import (#907)")
|
console.event(" Province Boundary Import (#907)")
|
||||||
print(f" DB: {db_path}")
|
console.event(f" DB: {db_path}")
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(" Mode: DRY RUN (no DB writes)")
|
console.event(" Mode: DRY RUN (no DB writes)")
|
||||||
if args.force:
|
if args.force:
|
||||||
print(" Force: enabled (will overwrite existing rows)")
|
console.event(" Force: enabled (will overwrite existing rows)")
|
||||||
print()
|
|
||||||
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
@@ -495,12 +496,10 @@ def main() -> None:
|
|||||||
if args.body:
|
if args.body:
|
||||||
bodies = [b for b in bodies if b["body_id"] == args.body]
|
bodies = [b for b in bodies if b["body_id"] == args.body]
|
||||||
if not bodies:
|
if not bodies:
|
||||||
print(f"error: body '{args.body}' not found or has no terrain_reference",
|
|
||||||
file=sys.stderr)
|
|
||||||
conn.close()
|
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()
|
t_total = time.time()
|
||||||
n_imported = 0
|
n_imported = 0
|
||||||
@@ -518,28 +517,28 @@ def main() -> None:
|
|||||||
|
|
||||||
if status == "imported":
|
if status == "imported":
|
||||||
n_imported += 1
|
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":
|
elif status == "skipped":
|
||||||
n_skipped += 1
|
n_skipped += 1
|
||||||
if args.verbose:
|
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":
|
elif status == "no_heightmap":
|
||||||
n_no_hmap += 1
|
n_no_hmap += 1
|
||||||
if args.verbose:
|
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":
|
elif status == "error":
|
||||||
n_errors += 1
|
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()
|
conn.close()
|
||||||
|
|
||||||
elapsed_total = time.time() - t_total
|
elapsed_total = time.time() - t_total
|
||||||
print(f"\n Done in {elapsed_total:.1f}s")
|
console.event(f" Done in {elapsed_total:.1f}s")
|
||||||
print(f" imported={n_imported} skipped={n_skipped} "
|
console.event(f" imported={n_imported} skipped={n_skipped} "
|
||||||
f"no_heightmap={n_no_hmap} errors={n_errors}")
|
f"no_heightmap={n_no_hmap} errors={n_errors}")
|
||||||
|
|
||||||
if n_errors > 0:
|
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__":
|
if __name__ == "__main__":
|
||||||
+5
-4
@@ -25,7 +25,7 @@ Outputs:
|
|||||||
PIL Image (RGBA, 2048×2048) — caller saves as PNG
|
PIL Image (RGBA, 2048×2048) — caller saves as PNG
|
||||||
|
|
||||||
Usage:
|
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 = render_globe(body_def, terrain=None)
|
||||||
img.save("myplanet.png")
|
img.save("myplanet.png")
|
||||||
|
|
||||||
@@ -43,13 +43,14 @@ import math
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from biome_config import (
|
from tooling.domains.atlas.planet.biome_config import (
|
||||||
BIOME_PALETTE as _BIOME_PALETTE_CFG,
|
BIOME_PALETTE as _BIOME_PALETTE_CFG,
|
||||||
STAR_TINTS as _STAR_TINTS_CFG,
|
STAR_TINTS as _STAR_TINTS_CFG,
|
||||||
ATMO_COLORS as _ATMO_COLORS_CFG,
|
ATMO_COLORS as _ATMO_COLORS_CFG,
|
||||||
GAS_PALETTES as _GAS_PALETTES_CFG,
|
GAS_PALETTES as _GAS_PALETTES_CFG,
|
||||||
MAX_BIOME_ID,
|
MAX_BIOME_ID,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Output resolution
|
# Output resolution
|
||||||
@@ -969,7 +970,7 @@ if __name__ == "__main__":
|
|||||||
out = os.path.join(out_dir, f"{bd['id']}.png")
|
out = os.path.join(out_dir, f"{bd['id']}.png")
|
||||||
img.save(out, format="PNG")
|
img.save(out, format="PNG")
|
||||||
dt = time.time() - t0
|
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)
|
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
|
import numpy as np
|
||||||
from scipy.ndimage import gaussian_filter
|
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,
|
WHITTAKER_TABLE, CLASS_T_BAND, EXOTIC_CLASSES, CRATER_SCALING,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
# Canonical heightmap grid (D-202 amended, #963): bumped to 1024×512 so the
|
# Canonical heightmap grid (D-202 amended, #963): bumped to 1024×512 so the
|
||||||
@@ -902,7 +903,7 @@ if __name__ == "__main__":
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
with open(sys.argv[1]) as f:
|
with open(sys.argv[1]) as f:
|
||||||
@@ -910,21 +911,21 @@ if __name__ == "__main__":
|
|||||||
|
|
||||||
save_grids = "--save-grids" in sys.argv
|
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()
|
t0 = time.time()
|
||||||
terrain = simulate(bd)
|
terrain = simulate(bd)
|
||||||
|
|
||||||
if not terrain:
|
if not terrain:
|
||||||
print("Gas giant — no terrain simulation.")
|
console.event("Gas giant — no terrain simulation.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
dt = time.time() - t0
|
dt = time.time() - t0
|
||||||
print(f"Done in {dt:.1f}s")
|
console.event(f"Done in {dt:.1f}s")
|
||||||
print(f" sea_level: {terrain['sea_level']:.3f}")
|
console.event(f" sea_level: {terrain['sea_level']:.3f}")
|
||||||
print(f" land cells: {(~terrain['surface_water']).sum()}")
|
console.event(f" land cells: {(~terrain['surface_water']).sum()}")
|
||||||
|
|
||||||
ids, counts = np.unique(terrain['biome'], return_counts=True)
|
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:
|
if save_grids:
|
||||||
out = f"/tmp/{bd['id']}_grids"
|
out = f"/tmp/{bd['id']}_grids"
|
||||||
@@ -933,4 +934,4 @@ if __name__ == "__main__":
|
|||||||
arr = terrain[name]
|
arr = terrain[name]
|
||||||
Image.fromarray((arr * 255).astype("uint8"), "L").save(
|
Image.fromarray((arr * 255).astype("uint8"), "L").save(
|
||||||
f"{out}/{name}.png")
|
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).
|
This is the prerequisite for the atlas importers (import_heightmaps.py, #901).
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 tooling/planet-gen/populate_terrain_reference.py
|
reach atlas planet terrain-reference
|
||||||
python3 tooling/planet-gen/populate_terrain_reference.py --dry-run
|
reach atlas planet terrain-reference --dry-run
|
||||||
python3 tooling/planet-gen/populate_terrain_reference.py --db path/to/systems.db
|
reach atlas planet terrain-reference --db path/to/systems.db
|
||||||
|
|
||||||
Decisions: D-191 (atlas pipeline prerequisites)
|
Decisions: D-191 (atlas pipeline prerequisites)
|
||||||
"""
|
"""
|
||||||
@@ -24,8 +24,11 @@ import argparse
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
from tooling.core import config, console
|
||||||
REPO_ROOT = (SCRIPT_DIR / ".." / "..").resolve()
|
|
||||||
|
|
||||||
|
PLANET_DIR = Path(__file__).resolve().parent
|
||||||
|
REPO_ROOT = config.repo_root()
|
||||||
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
|
||||||
WIKI_DIR = REPO_ROOT / "wiki" / "star-systems"
|
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"
|
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(
|
parser = argparse.ArgumentParser(
|
||||||
description="Populate terrain_reference column in systems.db bodies table"
|
description="Populate terrain_reference column in systems.db bodies table"
|
||||||
)
|
)
|
||||||
@@ -63,11 +66,11 @@ def main():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Report without writing changes",
|
help="Report without writing changes",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
db_path = Path(args.db)
|
db_path = Path(args.db)
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
print(f"error: {db_path} not found")
|
console.event(f"error: {db_path} not found")
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -81,11 +84,11 @@ def main():
|
|||||||
ORDER BY b.system_id, b.body_id
|
ORDER BY b.system_id, b.body_id
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
|
|
||||||
print("\n terrain_reference population pass")
|
console.event(" terrain_reference population pass")
|
||||||
print(f" DB: {db_path}")
|
console.event(f" DB: {db_path}")
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(" Mode: DRY RUN")
|
console.event(" Mode: DRY RUN")
|
||||||
print(f"\n {len(rows)} bodies with NULL terrain_reference\n")
|
console.event(f" {len(rows)} bodies with NULL terrain_reference\n")
|
||||||
|
|
||||||
found = []
|
found = []
|
||||||
missing = []
|
missing = []
|
||||||
@@ -99,16 +102,15 @@ def main():
|
|||||||
|
|
||||||
# Report missing heightmaps before writing — helps flag gaps early.
|
# Report missing heightmaps before writing — helps flag gaps early.
|
||||||
if missing:
|
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:
|
for body_id, system_id, path in missing:
|
||||||
print(f" {body_id} ({system_id}) → {path}")
|
console.event(f" {body_id} ({system_id}) → {path}")
|
||||||
print()
|
|
||||||
|
|
||||||
if found:
|
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:
|
for body_id, system_id in found:
|
||||||
ref = relative_terrain_reference(system_id, body_id)
|
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:
|
if not args.dry_run:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE bodies SET terrain_reference = ? WHERE body_id = ?",
|
"UPDATE bodies SET terrain_reference = ? WHERE body_id = ?",
|
||||||
@@ -117,22 +119,22 @@ def main():
|
|||||||
|
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print(f"\n Committed {len(found)} terrain_reference updates.")
|
console.event(f" Committed {len(found)} terrain_reference updates.")
|
||||||
else:
|
else:
|
||||||
print("\n Dry run — no changes written.")
|
console.event(" Dry run — no changes written.")
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
print("\n Summary:")
|
console.event(" Summary:")
|
||||||
print(f" Updated: {len(found)}")
|
console.event(f" Updated: {len(found)}")
|
||||||
print(f" Missing: {len(missing)}")
|
console.event(f" Missing: {len(missing)}")
|
||||||
print(f" Total: {len(rows)}\n")
|
console.event(f" Total: {len(rows)}\n")
|
||||||
|
|
||||||
if missing:
|
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).")
|
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__":
|
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.
|
Those live in a separate JSON sidecar and are overlaid by the atlas app.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from render_heightmap import render_heightmap
|
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
|
||||||
from planet_simulation import simulate
|
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||||
from body_definition_parser import parse_system
|
from tooling.domains.atlas.planet.body_definition_parser import parse_system
|
||||||
|
|
||||||
defs = parse_system("index.md")
|
defs = parse_system("index.md")
|
||||||
terrain = simulate(defs[0])
|
terrain = simulate(defs[0])
|
||||||
@@ -41,12 +41,13 @@ import numpy as np
|
|||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from scipy.ndimage import binary_dilation
|
from scipy.ndimage import binary_dilation
|
||||||
|
|
||||||
from biome_config import (
|
from tooling.domains.atlas.planet.biome_config import (
|
||||||
BIOME_PALETTE as _BIOME_PALETTE_CFG,
|
BIOME_PALETTE as _BIOME_PALETTE_CFG,
|
||||||
RIVER_RGB as _RIVER_RGB_CFG,
|
RIVER_RGB as _RIVER_RGB_CFG,
|
||||||
COAST_RGB as _COAST_RGB_CFG,
|
COAST_RGB as _COAST_RGB_CFG,
|
||||||
build_biome_rgb,
|
build_biome_rgb,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Output resolution
|
# Output resolution
|
||||||
@@ -423,7 +424,7 @@ if __name__ == "__main__":
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
with open(sys.argv[1]) as f:
|
with open(sys.argv[1]) as f:
|
||||||
@@ -433,23 +434,23 @@ if __name__ == "__main__":
|
|||||||
large = "--large" in sys.argv
|
large = "--large" in sys.argv
|
||||||
w, h = (4096, 2048) if large else (OUT_W, OUT_H)
|
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()
|
t0 = time.time()
|
||||||
terrain = simulate(bd)
|
terrain = simulate(bd)
|
||||||
sim_t = time.time() - t0
|
sim_t = time.time() - t0
|
||||||
|
|
||||||
if not terrain:
|
if not terrain:
|
||||||
print("Gas giant — no heightmap.")
|
console.event("Gas giant — no heightmap.")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
print(f"Rendering heightmap {w}×{h}…")
|
console.event(f"Rendering heightmap {w}×{h}…")
|
||||||
t1 = time.time()
|
t1 = time.time()
|
||||||
img = render_heightmap(bd, terrain, out_w=w, out_h=h)
|
img = render_heightmap(bd, terrain, out_w=w, out_h=h)
|
||||||
ren_t = time.time() - t1
|
ren_t = time.time() - t1
|
||||||
|
|
||||||
out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png"
|
out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png"
|
||||||
img.save(out, format="PNG")
|
img.save(out, format="PNG")
|
||||||
print(f"Saved: {out}")
|
console.event(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" 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).
|
Below the frontmatter is space for authored body content (narrative, notes).
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md
|
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md
|
||||||
python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md --overrides sol_overrides.json
|
reach atlas planet scaffold 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 --dry-run
|
||||||
|
|
||||||
Only creates files that don't exist yet — never overwrites authored content.
|
Only creates files that don't exist yet — never overwrites authored content.
|
||||||
Re-running is safe: existing body index.md files are skipped.
|
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 argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tooling.core import config, console
|
||||||
|
from tooling.core.errors import ReachError
|
||||||
|
|
||||||
# Venv bootstrap
|
# Venv bootstrap
|
||||||
TOOLING_DIR = Path(__file__).resolve().parent
|
PLANET_DIR = Path(__file__).resolve().parent
|
||||||
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
WORKTREE_ROOT = config.repo_root()
|
||||||
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
|
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
|
||||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
# script under .venv/bin/python so numpy would resolve when run by path.
|
||||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
# 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:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# PyYAML is in pyproject.toml deps
|
# PyYAML is in pyproject.toml deps
|
||||||
print("error: PyYAML not installed — run `make setup-venv`", file=sys.stderr)
|
raise ReachError("PyYAML not installed — run `make setup-venv`", fix="make install-reach — reach declares PyYAML in its own environment")
|
||||||
sys.exit(1)
|
|
||||||
|
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:
|
def _body_to_frontmatter(bd: dict) -> str:
|
||||||
@@ -147,14 +150,14 @@ def _body_prose(bd: dict, system_dir: Path) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(argv: list[str] | None = None):
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Scaffold per-body index.md files from a system index.md")
|
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("system_index", help="Path to system index.md")
|
||||||
parser.add_argument("--overrides", help="Per-body overrides JSON")
|
parser.add_argument("--overrides", help="Per-body overrides JSON")
|
||||||
parser.add_argument("--dry-run", action="store_true",
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
help="Print what would be created without writing")
|
help="Print what would be created without writing")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
system_path = Path(args.system_index)
|
system_path = Path(args.system_index)
|
||||||
system_dir = system_path.parent
|
system_dir = system_path.parent
|
||||||
@@ -166,7 +169,7 @@ def main():
|
|||||||
overrides = json.load(f)
|
overrides = json.load(f)
|
||||||
|
|
||||||
body_defs = parse_system(str(system_path), overrides=overrides)
|
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
|
created = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
@@ -176,7 +179,7 @@ def main():
|
|||||||
index_path = body_dir / "index.md"
|
index_path = body_dir / "index.md"
|
||||||
|
|
||||||
if index_path.exists():
|
if index_path.exists():
|
||||||
print(f" skip {body_id} — index.md exists")
|
console.event(f" skip {body_id} — index.md exists")
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -185,18 +188,18 @@ def main():
|
|||||||
content = f"---\n{frontmatter}\n---\n\n{prose}"
|
content = f"---\n{frontmatter}\n---\n\n{prose}"
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(f" would create {index_path}")
|
console.event(f" would create {index_path}")
|
||||||
print(f" {bd.get('planet_class', '?')} / {bd.get('body_type', '?')}")
|
console.event(f" {bd.get('planet_class', '?')} / {bd.get('body_type', '?')}")
|
||||||
else:
|
else:
|
||||||
body_dir.mkdir(parents=True, exist_ok=True)
|
body_dir.mkdir(parents=True, exist_ok=True)
|
||||||
with open(index_path, "w") as f:
|
with open(index_path, "w") as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
print(f" created {index_path}")
|
console.event(f" created {index_path}")
|
||||||
|
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
action = "would create" if args.dry_run else "created"
|
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__":
|
if __name__ == "__main__":
|
||||||
+27
-12
@@ -6,25 +6,41 @@ Supports resume for large files and optional SHA-256 verification.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
CACHE_DIR = Path(__file__).resolve().parent / ".cache"
|
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):
|
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
|
downloaded = block_num * block_size
|
||||||
|
mb = downloaded / (1024 * 1024)
|
||||||
if total_size > 0:
|
if total_size > 0:
|
||||||
pct = min(100.0, downloaded * 100.0 / total_size)
|
fraction = min(1.0, downloaded / total_size)
|
||||||
mb = downloaded / (1024 * 1024)
|
step = int(fraction * 100) // _STEP_PCT
|
||||||
total_mb = total_size / (1024 * 1024)
|
if step != _last_step:
|
||||||
sys.stdout.write(f"\r downloading: {mb:.1f}/{total_mb:.1f} MB ({pct:.0f}%)")
|
_last_step = step
|
||||||
|
total_mb = total_size / (1024 * 1024)
|
||||||
|
console.event(f"downloading: {mb:.1f}/{total_mb:.1f} MB", progress=fraction)
|
||||||
else:
|
else:
|
||||||
mb = downloaded / (1024 * 1024)
|
step = int(mb) // _STEP_MB
|
||||||
sys.stdout.write(f"\r downloading: {mb:.1f} MB")
|
if step != _last_step:
|
||||||
sys.stdout.flush()
|
_last_step = step
|
||||||
|
console.event(f"downloading: {mb:.1f} MB")
|
||||||
|
|
||||||
|
|
||||||
def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
|
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:
|
if sha256:
|
||||||
actual = _sha256(local_path)
|
actual = _sha256(local_path)
|
||||||
if actual != sha256:
|
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()
|
local_path.unlink()
|
||||||
else:
|
else:
|
||||||
return local_path
|
return local_path
|
||||||
else:
|
else:
|
||||||
return local_path
|
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")
|
tmp_path = local_path.with_suffix(".tmp")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -62,7 +78,6 @@ def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
|
|||||||
]
|
]
|
||||||
urllib.request.install_opener(opener)
|
urllib.request.install_opener(opener)
|
||||||
urllib.request.urlretrieve(url, str(tmp_path), reporthook=_progress_hook)
|
urllib.request.urlretrieve(url, str(tmp_path), reporthook=_progress_hook)
|
||||||
print() # newline after progress
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if tmp_path.exists():
|
if tmp_path.exists():
|
||||||
tmp_path.unlink()
|
tmp_path.unlink()
|
||||||
+18
-19
@@ -16,13 +16,14 @@ import zipfile
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sol_data.download import ensure_cached
|
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||||
from sol_data.shared import (
|
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||||
GRID_W, GRID_H,
|
GRID_W, GRID_H,
|
||||||
load_tiff_as_array,
|
load_tiff_as_array,
|
||||||
resample_to_grid, normalize_01, compute_sea_level,
|
resample_to_grid, normalize_01, compute_sea_level,
|
||||||
compute_hillshade, assemble_terrain,
|
compute_hillshade, assemble_terrain,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# ─── Data source URLs ───────────────────────────────────────────────────────
|
# ─── Data source URLs ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ def _match_river_name(feature_name: str) -> str:
|
|||||||
def _load_etopo() -> np.ndarray:
|
def _load_etopo() -> np.ndarray:
|
||||||
"""Load ETOPO 2022 elevation data, return raw metres array."""
|
"""Load ETOPO 2022 elevation data, return raw metres array."""
|
||||||
path = ensure_cached(ETOPO_URL, ETOPO_FILE)
|
path = ensure_cached(ETOPO_URL, ETOPO_FILE)
|
||||||
print(f" loading ETOPO: {path}")
|
console.event(f" loading ETOPO: {path}")
|
||||||
try:
|
try:
|
||||||
arr = load_tiff_as_array(str(path))
|
arr = load_tiff_as_array(str(path))
|
||||||
except Exception as e:
|
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"If PIL can't read this TIFF, install Pillow with TIFF support "
|
||||||
f"or convert to raw binary."
|
f"or convert to raw binary."
|
||||||
) from e
|
) 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
|
return arr
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +133,7 @@ def _load_worldclim_temperature() -> np.ndarray:
|
|||||||
Returns temperature in Kelvin at native resolution.
|
Returns temperature in Kelvin at native resolution.
|
||||||
"""
|
"""
|
||||||
zip_path = ensure_cached(WCLIM_TEMP_URL, WCLIM_TEMP_FILE)
|
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).
|
# The zip contains monthly TIFFs (tavg_01.tif to tavg_12.tif).
|
||||||
# Compute annual mean from all 12 months.
|
# 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
|
# Replace NaN (ocean/nodata) with a reasonable ocean temperature
|
||||||
temp_K = np.nan_to_num(temp_K, nan=288.0)
|
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")
|
f"range: [{np.nanmin(temp_K):.0f}, {np.nanmax(temp_K):.0f}] K")
|
||||||
return temp_K
|
return temp_K
|
||||||
|
|
||||||
@@ -188,7 +189,7 @@ def _load_worldclim_precipitation() -> np.ndarray:
|
|||||||
Returns precipitation in mm/year at native resolution.
|
Returns precipitation in mm/year at native resolution.
|
||||||
"""
|
"""
|
||||||
zip_path = ensure_cached(WCLIM_PREC_URL, WCLIM_PREC_FILE)
|
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
|
cache_dir = zip_path.parent
|
||||||
annual_sum = None
|
annual_sum = None
|
||||||
@@ -219,7 +220,7 @@ def _load_worldclim_precipitation() -> np.ndarray:
|
|||||||
if annual_sum is None:
|
if annual_sum is None:
|
||||||
raise RuntimeError("No precipitation TIFFs found in WorldClim archive")
|
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")
|
f"range: [{annual_sum.min():.0f}, {annual_sum.max():.0f}] mm/yr")
|
||||||
return annual_sum
|
return annual_sum
|
||||||
|
|
||||||
@@ -230,7 +231,7 @@ def _load_rivers_geojson() -> list:
|
|||||||
Returns list of (name, [(row, col), ...]) in grid coordinates.
|
Returns list of (name, [(row, col), ...]) in grid coordinates.
|
||||||
"""
|
"""
|
||||||
path = ensure_cached(RIVERS_URL, RIVERS_FILE)
|
path = ensure_cached(RIVERS_URL, RIVERS_FILE)
|
||||||
print(f" loading rivers: {path}")
|
console.event(f" loading rivers: {path}")
|
||||||
|
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
geojson = json.load(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]):
|
if name not in by_name or len(path) > len(by_name[name]):
|
||||||
by_name[name] = path
|
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()]
|
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().
|
Returns the same dict format as planet_simulation.simulate().
|
||||||
"""
|
"""
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import compute_biome
|
|
||||||
|
|
||||||
print(" Earth: loading real-world data...")
|
console.event(" Earth: loading real-world data...")
|
||||||
|
|
||||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||||
etopo_raw = _load_etopo()
|
etopo_raw = _load_etopo()
|
||||||
@@ -312,7 +311,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
sea_level = compute_sea_level(elevation, EARTH_OCEAN_FRACTION)
|
sea_level = compute_sea_level(elevation, EARTH_OCEAN_FRACTION)
|
||||||
surface_water = elevation < sea_level
|
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")
|
f"ocean={surface_water.sum()}/{GRID_H*GRID_W} cells")
|
||||||
|
|
||||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
# ── 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
|
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)
|
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 ─────────────────────────────────────────────────────
|
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||||
precip_raw = _load_worldclim_precipitation()
|
precip_raw = _load_worldclim_precipitation()
|
||||||
@@ -340,14 +339,14 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
# Ocean moisture = high (drives adjacent land humidity)
|
# Ocean moisture = high (drives adjacent land humidity)
|
||||||
moisture = np.where(surface_water, 0.9, moisture)
|
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 ─────────────────────────────────────────
|
# ── 4. Biome classification ─────────────────────────────────────────
|
||||||
# Use the existing Whittaker table with real temperature and moisture
|
# Use the existing Whittaker table with real temperature and moisture
|
||||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||||
temperature_K, moisture)
|
temperature_K, moisture)
|
||||||
n_biomes = len(np.unique(biome))
|
n_biomes = len(np.unique(biome))
|
||||||
print(f" biomes: {n_biomes} classes present")
|
console.event(f" biomes: {n_biomes} classes present")
|
||||||
|
|
||||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||||
hillshade = compute_hillshade(elevation)
|
hillshade = compute_hillshade(elevation)
|
||||||
@@ -369,7 +368,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
|
|
||||||
n_orig = len(named_rivers)
|
n_orig = len(named_rivers)
|
||||||
n_kept = len(clipped)
|
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
|
named_rivers = clipped
|
||||||
rivers = [path for _, path in named_rivers]
|
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,
|
definition parser. This module exists for future enhancement (ring tuning,
|
||||||
storm placement, etc).
|
storm placement, etc).
|
||||||
"""
|
"""
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
|
|
||||||
def validate_gas_giant_def(body_def: dict) -> bool:
|
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", {})
|
gg = body_def.get("gas_giant", {})
|
||||||
if not gg.get("band_palette"):
|
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 False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
+7
-9
@@ -10,16 +10,16 @@ Each moon gets specific temperature and appearance tuning.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
from scipy.ndimage import gaussian_filter
|
from scipy.ndimage import gaussian_filter
|
||||||
|
|
||||||
from sol_data.download import ensure_cached
|
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||||
from sol_data.shared import (
|
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||||
GRID_W, GRID_H,
|
GRID_W, GRID_H,
|
||||||
load_image_as_elevation, resample_to_grid, normalize_01,
|
load_image_as_elevation, resample_to_grid, normalize_01,
|
||||||
compute_hillshade, assemble_terrain,
|
compute_hillshade, assemble_terrain,
|
||||||
temperature_grid_analytical,
|
temperature_grid_analytical,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# ─── Per-moon configuration ─────────────────────────────────────────────────
|
# ─── 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."""
|
"""Load a global mosaic and convert to synthetic elevation."""
|
||||||
try:
|
try:
|
||||||
path = ensure_cached(config["mosaic_url"], config["mosaic_file"])
|
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),
|
albedo = load_image_as_elevation(str(path),
|
||||||
invert=config.get("invert_albedo", False))
|
invert=config.get("invert_albedo", False))
|
||||||
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
|
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
|
||||||
except Exception as e:
|
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"])
|
albedo = _synthetic_ice_terrain(config["name"])
|
||||||
|
|
||||||
# Smooth albedo to create plausible topography
|
# 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:
|
def build_terrain(body_def: dict) -> dict:
|
||||||
"""Build ice moon terrain dict from mosaic data."""
|
"""Build ice moon terrain dict from mosaic data."""
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import compute_biome
|
|
||||||
|
|
||||||
body_id = body_def["id"]
|
body_id = body_def["id"]
|
||||||
config = MOON_CONFIG.get(body_id)
|
config = MOON_CONFIG.get(body_id)
|
||||||
@@ -109,7 +107,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
if config is None:
|
if config is None:
|
||||||
raise ValueError(f"No ice moon config for {body_id}")
|
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 ────────────────────────────────────────────────────
|
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||||
elevation = _load_mosaic_as_elevation(config)
|
elevation = _load_mosaic_as_elevation(config)
|
||||||
+7
-9
@@ -16,16 +16,16 @@ Properties:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
from scipy.ndimage import gaussian_filter
|
from scipy.ndimage import gaussian_filter
|
||||||
|
|
||||||
from sol_data.download import ensure_cached
|
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||||
from sol_data.shared import (
|
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||||
GRID_W, GRID_H,
|
GRID_W, GRID_H,
|
||||||
load_image_as_elevation, resample_to_grid, normalize_01,
|
load_image_as_elevation, resample_to_grid, normalize_01,
|
||||||
compute_hillshade, assemble_terrain,
|
compute_hillshade, assemble_terrain,
|
||||||
temperature_grid_analytical,
|
temperature_grid_analytical,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# Io global mosaic (Galileo SSI + Voyager) — JPEG from USGS
|
# Io global mosaic (Galileo SSI + Voyager) — JPEG from USGS
|
||||||
# If direct download isn't available, fall back to procedural
|
# 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."""
|
"""Load Io global mosaic and convert to synthetic elevation."""
|
||||||
try:
|
try:
|
||||||
path = ensure_cached(IO_MOSAIC_URL, IO_MOSAIC_FILE)
|
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)
|
albedo = load_image_as_elevation(str(path), invert=False)
|
||||||
except Exception as e:
|
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()
|
return _synthetic_io_terrain()
|
||||||
|
|
||||||
# Resample to grid
|
# Resample to grid
|
||||||
@@ -76,11 +76,9 @@ def _synthetic_io_terrain() -> np.ndarray:
|
|||||||
|
|
||||||
def build_terrain(body_def: dict) -> dict:
|
def build_terrain(body_def: dict) -> dict:
|
||||||
"""Build Io terrain dict."""
|
"""Build Io terrain dict."""
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import compute_biome
|
|
||||||
|
|
||||||
print(" Io: loading data...")
|
console.event(" Io: loading data...")
|
||||||
|
|
||||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||||
elevation = _load_io_mosaic()
|
elevation = _load_io_mosaic()
|
||||||
+11
-13
@@ -14,16 +14,16 @@ Luna properties:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sol_data.download import ensure_cached
|
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||||
from sol_data.shared import (
|
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||||
GRID_W, GRID_H,
|
GRID_W, GRID_H,
|
||||||
load_raw_binary,
|
load_raw_binary,
|
||||||
resample_to_grid, normalize_01,
|
resample_to_grid, normalize_01,
|
||||||
compute_hillshade, assemble_terrain,
|
compute_hillshade, assemble_terrain,
|
||||||
temperature_grid_analytical,
|
temperature_grid_analytical,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# LOLA GDR — available as PDS IMG files
|
# LOLA GDR — available as PDS IMG files
|
||||||
# 4ppd (1440 × 720) — compact version
|
# 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
|
url, filename, w, h = LOLA_4PPD_URL, LOLA_4PPD_FILE, LOLA_4PPD_W, LOLA_4PPD_H
|
||||||
|
|
||||||
path = ensure_cached(url, filename)
|
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)
|
# LOLA GDR: little-endian int16 (LSB_INTEGER per PDS label)
|
||||||
# with a scaling factor of 0.5 metres.
|
# 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
|
||||||
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
|
return arr
|
||||||
|
|
||||||
|
|
||||||
def build_terrain(body_def: dict) -> dict:
|
def build_terrain(body_def: dict) -> dict:
|
||||||
"""Build Luna terrain dict from LOLA data."""
|
"""Build Luna terrain dict from LOLA data."""
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import compute_biome
|
|
||||||
|
|
||||||
print(" Luna: loading LOLA data...")
|
console.event(" Luna: loading LOLA data...")
|
||||||
|
|
||||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||||
lola_raw = _load_lola(use_16ppd=False)
|
lola_raw = _load_lola(use_16ppd=False)
|
||||||
|
|
||||||
# LOLA cylindrical: col 0 = 0° longitude — shift to 180°W
|
# 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)
|
lola_shifted = greenwich_to_dateline(lola_raw)
|
||||||
|
|
||||||
elevation_m = resample_to_grid(lola_shifted, GRID_H, GRID_W, order=1)
|
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
|
sea_level = 0.0
|
||||||
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
|
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
|
||||||
|
|
||||||
print(" elevation normalised")
|
console.event(" elevation normalised")
|
||||||
|
|
||||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||||
temperature_K = temperature_grid_analytical(
|
temperature_K = temperature_grid_analytical(
|
||||||
@@ -107,7 +105,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
# Clamp minimum
|
# Clamp minimum
|
||||||
temperature_K = np.maximum(temperature_K, 40.0)
|
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 ─────────────────────────────────────────────────────
|
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||||
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
|
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)
|
# body_type: "moon" + atmosphere: "none" → lunar palette (31/32/33)
|
||||||
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
biome = compute_biome(body_def, elevation, sea_level, surface_water,
|
||||||
temperature_K, moisture)
|
temperature_K, moisture)
|
||||||
print(f" biomes: {len(np.unique(biome))} classes")
|
console.event(f" biomes: {len(np.unique(biome))} classes")
|
||||||
|
|
||||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||||
hillshade = compute_hillshade(elevation)
|
hillshade = compute_hillshade(elevation)
|
||||||
+12
-11
@@ -17,13 +17,14 @@ Mars properties:
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from sol_data.download import ensure_cached
|
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||||
from sol_data.shared import (
|
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||||
GRID_W, GRID_H,
|
GRID_W, GRID_H,
|
||||||
load_raw_binary, resample_to_grid, normalize_01,
|
load_raw_binary, resample_to_grid, normalize_01,
|
||||||
compute_hillshade, assemble_terrain,
|
compute_hillshade, assemble_terrain,
|
||||||
temperature_grid_analytical,
|
temperature_grid_analytical,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# MOLA MEGDR — 4 pixels per degree (1440 × 720), big-endian int16
|
# MOLA MEGDR — 4 pixels per degree (1440 × 720), big-endian int16
|
||||||
# Each pixel = metres relative to Mars areoid
|
# 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
|
url, filename, w, h = MOLA_4PPD_URL, MOLA_4PPD_FILE, MOLA_4PPD_W, MOLA_4PPD_H
|
||||||
|
|
||||||
path = ensure_cached(url, filename)
|
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
|
# MOLA MEGDR: big-endian int16, metres, no header
|
||||||
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
|
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
|
||||||
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
|
return arr
|
||||||
|
|
||||||
|
|
||||||
def build_terrain(body_def: dict) -> dict:
|
def build_terrain(body_def: dict) -> dict:
|
||||||
"""Build Mars terrain dict from MOLA data."""
|
"""Build Mars terrain dict from MOLA data."""
|
||||||
print(" Mars: loading MOLA data...")
|
console.event(" Mars: loading MOLA data...")
|
||||||
|
|
||||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||||
mola_raw = _load_mola(use_16ppd=False)
|
mola_raw = _load_mola(use_16ppd=False)
|
||||||
|
|
||||||
# MOLA is col 0 = 0° longitude — shift to col 0 = 180°W
|
# 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)
|
mola_shifted = greenwich_to_dateline(mola_raw)
|
||||||
|
|
||||||
# Resample to grid
|
# Resample to grid
|
||||||
@@ -92,7 +93,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
# Normalise to [0, 1]
|
# Normalise to [0, 1]
|
||||||
elevation = normalize_01(elevation_m, MARS_MIN_ELEV_M, MARS_MAX_ELEV_M)
|
elevation = normalize_01(elevation_m, MARS_MIN_ELEV_M, MARS_MAX_ELEV_M)
|
||||||
|
|
||||||
print(" elevation normalised")
|
console.event(" elevation normalised")
|
||||||
|
|
||||||
# ── 2. Temperature ──────────────────────────────────────────────────
|
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||||
# Analytical: equatorial ~210K, polar ~150K, elevation lapse
|
# Analytical: equatorial ~210K, polar ~150K, elevation lapse
|
||||||
@@ -109,7 +110,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
polar_rows = lat_abs > 0.75
|
polar_rows = lat_abs > 0.75
|
||||||
temperature_K[polar_rows, :] = np.minimum(temperature_K[polar_rows, :], 155.0)
|
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 ─────────────────────────────────────────────────────
|
# ── 3. Moisture ─────────────────────────────────────────────────────
|
||||||
# Mars has almost no moisture — thin atmosphere
|
# Mars has almost no moisture — thin atmosphere
|
||||||
@@ -120,7 +121,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
# ── 4. Terraformed water bodies ─────────────────────────────────────
|
# ── 4. Terraformed water bodies ─────────────────────────────────────
|
||||||
# Lore: 800 years of partial terraforming. Water pools in the deepest
|
# Lore: 800 years of partial terraforming. Water pools in the deepest
|
||||||
# basins (Hellas, Utopia, Isidis). ~2% of surface is now liquid water.
|
# 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
|
from scipy.ndimage import binary_dilation
|
||||||
|
|
||||||
TERRAFORM_OCEAN_FRAC = 0.02 # 2% water coverage
|
TERRAFORM_OCEAN_FRAC = 0.02 # 2% water coverage
|
||||||
@@ -131,7 +132,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
surface_water[polar_rows, :] = False
|
surface_water[polar_rows, :] = False
|
||||||
|
|
||||||
n_water = int(surface_water.sum())
|
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})")
|
f"(sea_level={sea_level:.4f})")
|
||||||
|
|
||||||
# ── 5. Biome classification ─────────────────────────────────────────
|
# ── 5. Biome classification ─────────────────────────────────────────
|
||||||
@@ -170,7 +171,7 @@ def build_terrain(body_def: dict) -> dict:
|
|||||||
n_ferric = int(((biome >= 34) & (biome <= 36)).sum())
|
n_ferric = int(((biome >= 34) & (biome <= 36)).sum())
|
||||||
n_veg = int(((biome == 8) | (biome == 12)).sum())
|
n_veg = int(((biome == 8) | (biome == 12)).sum())
|
||||||
n_ocean = int(((biome >= 0) & (biome <= 2)).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})")
|
f"(ferric={n_ferric}, ice={n_ice}, veg={n_veg}, water={n_ocean})")
|
||||||
|
|
||||||
# ── 5. Hillshade ────────────────────────────────────────────────────
|
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||||
+13
-17
@@ -14,16 +14,16 @@ Mercury properties:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sol_data.download import ensure_cached
|
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||||
from sol_data.shared import (
|
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||||
GRID_W, GRID_H,
|
GRID_W, GRID_H,
|
||||||
load_tiff_as_array, load_raw_binary,
|
load_tiff_as_array, load_raw_binary,
|
||||||
resample_to_grid, normalize_01,
|
resample_to_grid, normalize_01,
|
||||||
compute_hillshade, assemble_terrain,
|
compute_hillshade, assemble_terrain,
|
||||||
temperature_grid_analytical,
|
temperature_grid_analytical,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# MESSENGER DEM — try PDS binary first (compact), fall back to USGS GeoTIFF
|
# 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"
|
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 PDS binary first (compact ~33 MB)
|
||||||
try:
|
try:
|
||||||
path = ensure_cached(MESSENGER_PDS_URL, MESSENGER_PDS_FILE)
|
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,
|
arr = load_raw_binary(str(path), MESSENGER_PDS_W, MESSENGER_PDS_H,
|
||||||
dtype=">i2", offset=0)
|
dtype=">i2", offset=0)
|
||||||
arr[arr > 20000] = 0.0
|
arr[arr > 20000] = 0.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
|
return arr
|
||||||
except Exception as e:
|
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)
|
# Fallback: USGS GeoTIFF (~506 MB)
|
||||||
try:
|
try:
|
||||||
path = ensure_cached(MESSENGER_TIFF_URL, MESSENGER_TIFF_FILE)
|
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 = load_tiff_as_array(str(path))
|
||||||
arr[arr < -20000] = 0.0
|
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
|
return arr
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
print(f" GeoTIFF also failed ({e2}), using procedural")
|
console.event(f" GeoTIFF also failed ({e2}), using procedural")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_terrain(body_def: dict) -> dict:
|
def build_terrain(body_def: dict) -> dict:
|
||||||
"""Build Mercury terrain dict from MESSENGER data."""
|
"""Build Mercury terrain dict from MESSENGER data."""
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import compute_biome
|
|
||||||
|
|
||||||
print(" Mercury: loading MESSENGER data...")
|
console.event(" Mercury: loading MESSENGER data...")
|
||||||
|
|
||||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||||
raw = _load_messenger()
|
raw = _load_messenger()
|
||||||
if raw is None:
|
if raw is None:
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import simulate
|
|
||||||
return simulate(body_def)
|
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)
|
shifted = greenwich_to_dateline(raw)
|
||||||
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
|
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)
|
elevation = normalize_01(elevation_m, MERCURY_MIN_ELEV_M, MERCURY_MAX_ELEV_M)
|
||||||
+10
-12
@@ -18,15 +18,15 @@ Properties:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
from scipy.ndimage import gaussian_filter
|
from scipy.ndimage import gaussian_filter
|
||||||
|
|
||||||
from sol_data.download import ensure_cached
|
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||||
from sol_data.shared import (
|
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||||
GRID_W, GRID_H,
|
GRID_W, GRID_H,
|
||||||
load_image_as_elevation, resample_to_grid, normalize_01,
|
load_image_as_elevation, resample_to_grid, normalize_01,
|
||||||
compute_hillshade, assemble_terrain,
|
compute_hillshade, assemble_terrain,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# Cassini ISS global mosaic
|
# Cassini ISS global mosaic
|
||||||
TITAN_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/5e5ba96a58d3b38ee6e7b1e94b8c44e6_titan_iss_p19658_mosaic_global_4km.jpg"
|
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."""
|
"""Load Titan mosaic and convert to synthetic elevation."""
|
||||||
try:
|
try:
|
||||||
path = ensure_cached(TITAN_MOSAIC_URL, TITAN_MOSAIC_FILE)
|
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 = load_image_as_elevation(str(path), invert=False)
|
||||||
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
|
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
|
||||||
except Exception as e:
|
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()
|
albedo = _synthetic_titan_terrain()
|
||||||
|
|
||||||
# Dark regions = low (lakes/flat), bright = dunes/highlands
|
# 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:
|
def build_terrain(body_def: dict) -> dict:
|
||||||
"""Build Titan terrain dict."""
|
"""Build Titan terrain dict."""
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import compute_biome
|
|
||||||
|
|
||||||
print(" Titan: loading data...")
|
console.event(" Titan: loading data...")
|
||||||
|
|
||||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||||
elevation = _load_titan_mosaic()
|
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
|
# Titan has methane lakes — set sea level to create them
|
||||||
# Lakes are concentrated at north polar regions
|
# Lakes are concentrated at north polar regions
|
||||||
# Use a low sea level so that only the darkest (lowest) areas become liquid
|
# 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)
|
sea_level = compute_sea_level(elevation, TITAN_METHANE_LAKE_FRACTION)
|
||||||
surface_water = elevation < sea_level
|
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)
|
equatorial_mask = (lat_abs < 0.6)[:, np.newaxis] * np.ones(GRID_W, dtype=bool)
|
||||||
surface_water = surface_water & ~equatorial_mask
|
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 ──────────────────────────────────────────────────
|
# ── 2. Temperature ──────────────────────────────────────────────────
|
||||||
# Titan has nearly uniform surface temp due to dense atmosphere + distance
|
# 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
|
# Override: methane lakes should be ocean classes, not ice
|
||||||
# (The biome function sets ocean depth bands for surface_water, which is
|
# (The biome function sets ocean depth bands for surface_water, which is
|
||||||
# what we want — methane lakes rendered like ocean)
|
# 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 ────────────────────────────────────────────────────
|
# ── 5. Hillshade ────────────────────────────────────────────────────
|
||||||
hillshade = compute_hillshade(elevation)
|
hillshade = compute_hillshade(elevation)
|
||||||
+14
-18
@@ -14,15 +14,15 @@ Venus properties:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from sol_data.download import ensure_cached
|
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
|
||||||
from sol_data.shared import (
|
from tooling.domains.atlas.planet.sol_data.shared import (
|
||||||
GRID_W, GRID_H,
|
GRID_W, GRID_H,
|
||||||
load_tiff_as_array, load_raw_binary, resample_to_grid, normalize_01,
|
load_tiff_as_array, load_raw_binary, resample_to_grid, normalize_01,
|
||||||
compute_hillshade, assemble_terrain,
|
compute_hillshade, assemble_terrain,
|
||||||
temperature_grid_analytical,
|
temperature_grid_analytical,
|
||||||
)
|
)
|
||||||
|
from tooling.core import console
|
||||||
|
|
||||||
# Magellan topography — USGS GeoTIFF (reliable, PIL-loadable)
|
# Magellan topography — USGS GeoTIFF (reliable, PIL-loadable)
|
||||||
MAGELLAN_TIFF_URL = "https://planetarymaps.usgs.gov/mosaic/Venus_Magellan_Topography_Global_4641m_v02.tif"
|
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 USGS GeoTIFF first (reliable, well-defined format)
|
||||||
try:
|
try:
|
||||||
path = ensure_cached(MAGELLAN_TIFF_URL, MAGELLAN_TIFF_FILE)
|
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))
|
arr = load_tiff_as_array(str(path))
|
||||||
# Handle nodata
|
# Handle nodata
|
||||||
arr[arr < -20000] = 0.0
|
arr[arr < -20000] = 0.0
|
||||||
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")
|
f"range: [{arr.min():.0f}, {arr.max():.0f}] m")
|
||||||
return arr
|
return arr
|
||||||
except Exception as e:
|
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
|
# PDS fallback — try common dimension/format combinations
|
||||||
try:
|
try:
|
||||||
path = ensure_cached(MAGELLAN_PDS_URL, MAGELLAN_PDS_FILE)
|
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)]:
|
for w, h in [(4096, 2048), (2048, 1024), (8192, 4096)]:
|
||||||
try:
|
try:
|
||||||
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
|
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
|
||||||
arr[arr > 20000] = 0.0
|
arr[arr > 20000] = 0.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
|
return arr
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
except Exception as e3:
|
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
|
# 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
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_terrain(body_def: dict) -> dict:
|
def build_terrain(body_def: dict) -> dict:
|
||||||
"""Build Venus terrain dict from Magellan data."""
|
"""Build Venus terrain dict from Magellan data."""
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import compute_biome
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import compute_biome
|
|
||||||
|
|
||||||
print(" Venus: loading Magellan data...")
|
console.event(" Venus: loading Magellan data...")
|
||||||
|
|
||||||
# ── 1. Elevation ────────────────────────────────────────────────────
|
# ── 1. Elevation ────────────────────────────────────────────────────
|
||||||
raw = _load_magellan()
|
raw = _load_magellan()
|
||||||
if raw is None:
|
if raw is None:
|
||||||
# Fall back to procedural simulation
|
# Fall back to procedural simulation
|
||||||
import sys
|
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
from planet_simulation import simulate
|
|
||||||
return simulate(body_def)
|
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)
|
shifted = greenwich_to_dateline(raw)
|
||||||
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
|
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)
|
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.
|
planetary science data instead of procedural simulation.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 sol_import.py # All Sol bodies
|
reach atlas planet sol-import # All Sol bodies
|
||||||
python3 sol_import.py --body GJ0d # Earth only
|
reach atlas planet sol-import --body GJ0d # Earth only
|
||||||
python3 sol_import.py --body GJ0d --body GJ0e # Earth + Mars
|
reach atlas planet sol-import --body GJ0d --body GJ0e # Earth + Mars
|
||||||
python3 sol_import.py --download-only # Fetch data, skip rendering
|
reach atlas planet sol-import --download-only # Fetch data, skip rendering
|
||||||
python3 sol_import.py --heightmap-size 2048x1024 --globe-size 1024
|
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 argparse
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
|
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
TOOLING_DIR = Path(__file__).resolve().parent
|
|
||||||
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
|
from tooling.core import config, console
|
||||||
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
|
from tooling.core.errors import ReachError
|
||||||
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
|
PLANET_DIR = Path(__file__).resolve().parent
|
||||||
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
|
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
|
import numpy as np
|
||||||
|
|
||||||
from planet_simulation import simulate
|
from tooling.domains.atlas.planet.planet_simulation import simulate
|
||||||
from render_heightmap import render_heightmap
|
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
|
||||||
from generate import _build_markers
|
from tooling.domains.atlas.planet.generate import _build_markers
|
||||||
|
|
||||||
|
|
||||||
# Per-body importers (lazy-loaded)
|
# Per-body importers (lazy-loaded)
|
||||||
SOL_INDEX = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "index.md"
|
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_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)
|
# Bodies that use real-world data (keyed by body_id → importer module)
|
||||||
REAL_DATA_BODIES = {
|
REAL_DATA_BODIES = {
|
||||||
@@ -67,7 +71,7 @@ SKIP_TYPES = {"asteroid_belt", "oort_cloud"}
|
|||||||
def _load_importer(module_name: str):
|
def _load_importer(module_name: str):
|
||||||
"""Lazy-import a sol_data.* module."""
|
"""Lazy-import a sol_data.* module."""
|
||||||
import importlib
|
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:
|
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
|
# Skip non-renderable types
|
||||||
if body_type in SKIP_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
|
return
|
||||||
|
|
||||||
body_dir = output_dir / body_id
|
body_dir = output_dir / body_id
|
||||||
body_dir.mkdir(parents=True, exist_ok=True)
|
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()
|
t0 = time.time()
|
||||||
|
|
||||||
@@ -190,33 +194,33 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
|
|||||||
if is_gas:
|
if is_gas:
|
||||||
# Gas giants: no terrain, renderer handles bands procedurally
|
# Gas giants: no terrain, renderer handles bands procedurally
|
||||||
terrain = {}
|
terrain = {}
|
||||||
print(" terrain: gas giant (procedural bands)")
|
console.event(" terrain: gas giant (procedural bands)")
|
||||||
elif body_id in REAL_DATA_BODIES:
|
elif body_id in REAL_DATA_BODIES:
|
||||||
# Real-world data import
|
# Real-world data import
|
||||||
module_name = REAL_DATA_BODIES[body_id]
|
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)
|
importer = _load_importer(module_name)
|
||||||
terrain = importer.build_terrain(body_def)
|
terrain = importer.build_terrain(body_def)
|
||||||
if download_only:
|
if download_only:
|
||||||
print(" download complete, skipping render")
|
console.event(" download complete, skipping render")
|
||||||
return
|
return
|
||||||
elif body_id in PROCEDURAL_BODIES:
|
elif body_id in PROCEDURAL_BODIES:
|
||||||
# Fall through to standard procedural simulation
|
# Fall through to standard procedural simulation
|
||||||
print(" procedural simulation (irregular body)...")
|
console.event(" procedural simulation (irregular body)...")
|
||||||
terrain = simulate(body_def)
|
terrain = simulate(body_def)
|
||||||
else:
|
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)
|
terrain = simulate(body_def)
|
||||||
|
|
||||||
t_terrain = time.time()
|
t_terrain = time.time()
|
||||||
|
|
||||||
if terrain:
|
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"sea={terrain['sea_level']:.3f} "
|
||||||
f"land={int((~terrain['surface_water']).sum())} "
|
f"land={int((~terrain['surface_water']).sum())} "
|
||||||
f"rivers={len(terrain['rivers'])}")
|
f"rivers={len(terrain['rivers'])}")
|
||||||
else:
|
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 ─────────────────────────────────────────────
|
# ── 2. Render heightmap ─────────────────────────────────────────────
|
||||||
t_hmap = t_terrain
|
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)
|
render_mode=render_mode, chrome=False)
|
||||||
hmap_img.save(str(body_dir / "heightmap.png"))
|
hmap_img.save(str(body_dir / "heightmap.png"))
|
||||||
t_hmap = time.time()
|
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 ─────────────────────────────────────────────────
|
# ── 3. Render globe ─────────────────────────────────────────────────
|
||||||
try:
|
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 = render_globe(body_def, terrain, size=globe_size)
|
||||||
globe_img.save(str(body_dir / "globe.png"))
|
globe_img.save(str(body_dir / "globe.png"))
|
||||||
t_globe = time.time()
|
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:
|
except Exception as e:
|
||||||
print(f" globe: FAILED — {e}")
|
console.event(f" globe: FAILED — {e}")
|
||||||
t_globe = time.time()
|
t_globe = time.time()
|
||||||
|
|
||||||
# ── 4. Write data files ─────────────────────────────────────────────
|
# ── 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)
|
_write_index_md(body_def, body_dir)
|
||||||
|
|
||||||
elapsed = time.time() - t0
|
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):
|
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)
|
f.write(md)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main(argv: list[str] | None = None):
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Sol system (GJ-0) real-world terrain importer")
|
description="Sol system (GJ-0) real-world terrain importer")
|
||||||
|
|
||||||
@@ -315,21 +319,19 @@ def main():
|
|||||||
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
|
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
|
||||||
default="cartographic")
|
default="cartographic")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
# Parse heightmap size
|
# Parse heightmap size
|
||||||
try:
|
try:
|
||||||
hw, hh = args.heightmap_size.lower().split("x")
|
hw, hh = args.heightmap_size.lower().split("x")
|
||||||
hmap_w, hmap_h = int(hw), int(hh)
|
hmap_w, hmap_h = int(hw), int(hh)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print(f"error: invalid heightmap size '{args.heightmap_size}'",
|
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
|
||||||
file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
output_dir = Path(args.output_dir) if args.output_dir else SOL_BODIES_DIR
|
output_dir = Path(args.output_dir) if args.output_dir else SOL_BODIES_DIR
|
||||||
|
|
||||||
# Parse body definitions from GJ-0 index.md
|
# 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 = {}
|
overrides = {}
|
||||||
if SOL_OVERRIDES.exists():
|
if SOL_OVERRIDES.exists():
|
||||||
@@ -337,15 +339,14 @@ def main():
|
|||||||
overrides = json.load(f)
|
overrides = json.load(f)
|
||||||
|
|
||||||
body_defs = parse_system(str(SOL_INDEX), overrides=overrides)
|
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
|
# Filter to requested bodies
|
||||||
if args.body:
|
if args.body:
|
||||||
requested = set(args.body)
|
requested = set(args.body)
|
||||||
body_defs = [bd for bd in body_defs if bd["id"] in requested]
|
body_defs = [bd for bd in body_defs if bd["id"] in requested]
|
||||||
if not body_defs:
|
if not body_defs:
|
||||||
print(f"error: no matching bodies for {args.body}", file=sys.stderr)
|
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")
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Generate
|
# Generate
|
||||||
t_total = time.time()
|
t_total = time.time()
|
||||||
@@ -356,14 +357,14 @@ def main():
|
|||||||
args.render_mode, output_dir,
|
args.render_mode, output_dir,
|
||||||
download_only=args.download_only)
|
download_only=args.download_only)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n FAILED: {bd['id']} — {e}")
|
console.event(f" FAILED: {bd['id']} — {e}")
|
||||||
failed.append(bd["id"])
|
failed.append(bd["id"])
|
||||||
|
|
||||||
elapsed = time.time() - t_total
|
elapsed = time.time() - t_total
|
||||||
n_ok = len(body_defs) - len(failed)
|
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:
|
if failed:
|
||||||
print(f" Failed: {', '.join(failed)}")
|
console.event(f" Failed: {', '.join(failed)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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.
|
All names are real-world geographic names for Sol bodies.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 sol_name_fixes.py # apply all fixes
|
reach atlas planet sol-name-fixes # apply all fixes
|
||||||
python3 sol_name_fixes.py --dry-run # print planned changes without writing
|
reach atlas planet sol-name-fixes --dry-run # print planned changes without writing
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
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"
|
WIKI = REPO_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
|
||||||
|
|
||||||
# Keys are feature IDs; values are the names to assign.
|
# 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():
|
for body_id, body_fixes in FIXES.items():
|
||||||
path = WIKI / body_id / "markers.json"
|
path = WIKI / body_id / "markers.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
print(f" SKIP {body_id}: markers.json not found")
|
console.event(f" SKIP {body_id}: markers.json not found")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
@@ -117,24 +119,23 @@ def apply_sol_fixes(dry_run: bool = False) -> None:
|
|||||||
old = feature.get("name")
|
old = feature.get("name")
|
||||||
new = id_map[fid]
|
new = id_map[fid]
|
||||||
if old != new:
|
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:
|
if not dry_run:
|
||||||
feature["name"] = new
|
feature["name"] = new
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
if dry_run:
|
if dry_run:
|
||||||
print(f" (dry-run) Would write: {path}")
|
console.event(f" (dry-run) Would write: {path}")
|
||||||
else:
|
else:
|
||||||
with open(path, "w") as f:
|
with open(path, "w") as f:
|
||||||
json.dump(markers, f, indent=2)
|
json.dump(markers, f, indent=2)
|
||||||
print(f" Written: {path}")
|
console.event(f" Written: {path}")
|
||||||
else:
|
else:
|
||||||
print(f" No changes for {body_id}")
|
console.event(f" No changes for {body_id}")
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def main(argv: list[str] | None = None) -> None:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Name previously-unnamed Sol body auto-detected features."
|
description="Name previously-unnamed Sol body auto-detected features."
|
||||||
)
|
)
|
||||||
@@ -143,5 +144,9 @@ if __name__ == "__main__":
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="print planned changes without writing any files",
|
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)
|
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",
|
fix="re-run the capture, or check .cache/screenshots/ for the ladder",
|
||||||
exit_code=code,
|
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
|
# set is read from the registry rather than restated, so this list cannot drift
|
||||||
# from tooling/generator_sources.py (T-1067).
|
# from tooling/generator_sources.py (T-1067).
|
||||||
EXTRA_WATCHED = (
|
EXTRA_WATCHED = (
|
||||||
"tooling/planet-gen/import_heightmaps.py",
|
"tooling/domains/atlas/planet/import_heightmaps.py",
|
||||||
"tooling/planet-gen/import_province_boundaries.py",
|
"tooling/domains/atlas/planet/import_province_boundaries.py",
|
||||||
"server/data/systems-schema.sql",
|
"server/data/systems-schema.sql",
|
||||||
"wiki/star-systems/",
|
"wiki/star-systems/",
|
||||||
"wiki/economics/",
|
"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
|
from tooling.main import DOMAINS, _load_domain
|
||||||
|
|
||||||
report = []
|
report = []
|
||||||
for name in sorted(DOMAINS):
|
|
||||||
group = _load_domain(name)
|
def walk(name, group, prefix):
|
||||||
ctx = None
|
# A nested group (`atlas planet`) is not a verb: its callback only keeps it
|
||||||
for verb in group.list_commands(ctx):
|
# a group. Recurse into it instead, so the verbs underneath are held to the
|
||||||
cmd = group.get_command(ctx, verb)
|
# 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)
|
callback = getattr(cmd, "callback", None)
|
||||||
report.append({
|
report.append({
|
||||||
"domain": name,
|
"domain": name,
|
||||||
"verb": verb,
|
"verb": prefix + verb,
|
||||||
"decorated": bool(getattr(callback, MARKER, False)),
|
"decorated": bool(getattr(callback, MARKER, False)),
|
||||||
"help": (cmd.help or cmd.short_help or "").strip(),
|
"help": (cmd.help or cmd.short_help or "").strip(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for name in sorted(DOMAINS):
|
||||||
|
walk(name, _load_domain(name), "")
|
||||||
print(json.dumps(report))
|
print(json.dumps(report))
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
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
|
means a full regeneration. This asserts that simulating the same body twice
|
||||||
yields a bit-identical elevation array.
|
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.
|
Exit: 0 = deterministic, 1 = drift detected, 2 = could not find a test body.
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -14,13 +14,13 @@ import sqlite3
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent.parent
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(REPO / "tooling" / "planet-gen"))
|
sys.path.insert(0, str(REPO))
|
||||||
|
|
||||||
import numpy as np # noqa: E402
|
import numpy as np # noqa: E402
|
||||||
|
|
||||||
from body_definition_parser import parse_system # noqa: E402
|
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
|
||||||
from planet_simulation import GRID_H, GRID_W, simulate # noqa: E402
|
from tooling.domains.atlas.planet.planet_simulation import GRID_H, GRID_W, simulate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _elev_hash(terrain) -> str:
|
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
|
Pure arithmetic (no numpy/scipy dependency) — stdlib `unittest` only. Run
|
||||||
directly or via `make test-tooling`:
|
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 sys
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
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):
|
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