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

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

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

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

Two checks that could not fail:

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:08:02 +02:00
co-authored by Claude Opus 5.5
parent 201dabd19b
commit 668772075c
56 changed files with 1130 additions and 509 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ invented.
|---|---|---|
| `check` | repo consistency gates the push hook runs | `check-client-version` ✅, `check-canvas-version`, `check-systems-db-stamp`, `check-fact-ids`, `check-dataflow-graph.py` |
| `validate` | content and schema validation | `validate-content`, `validate-checklist`, `validate-ron` |
| `atlas` | **the whole spatial ladder** (D-191). Flat verbs for authoring and inspection; nested groups per rung for generation | flat: `atlas` (Rust binary), `atlas-check`, `atlas-names`, `atlas-commit-and-sync`, `atlas-systems-done`, `atlas-update-field`, `atlas-verify`, `atlas-flatness` · `atlas map`: the 5 star-map files · `atlas planet`: `planet-gen/` (30) |
| `atlas` | **the whole spatial ladder** (D-191). Flat verbs for authoring and inspection; nested groups per rung for generation | flat: `atlas` (Rust binary), `atlas-check`, `atlas-names`, `atlas-commit-and-sync`, `atlas-systems-done`, `atlas-update-field`, `atlas-verify`, `atlas-flatness` · `atlas map`: the 5 star-map files · `atlas planet`: ✅ ported (T-1288) from `planet-gen/` (30) — ten verbs, each restating its module's options for real `--help`; `test_planet_router.py` fails if a router option drifts from the module parser |
| ~~`starmap`~~ | **folded into `atlas map`** — the top rung of the same ladder | — |
| ~~`planet`~~ | **folded into `atlas planet`** — the third rung of the same ladder | — |
| `ledger` | the economics pipeline, named for the UI component that will aggregate it | `economy-db/` (17 files), `schema_version.py` |
+22
View File
@@ -0,0 +1,22 @@
"""`atlas planet` — the third rung of the spatial ladder (D-243).
Nested under `atlas` rather than standing alone because atlas coordinates
world-location generation: the ladder is one subject, and `planet` is a rung of
it, not a peer. Same reason `atlas map` holds the star-map verbs.
Formerly `tooling/planet-gen/`, 30 files reachable only by path. The move fixed
what a hyphenated directory made impossible — these modules import each other,
and until now did it by mutating `sys.path` at import time.
**Every repo-root computation here was wrong on arrival.** The originals
computed `Path(__file__).parent / ".." / ".."`, correct while the files sat two
levels deep and silently wrong at four. That is the fifth-through-ninth
instance of this trap in the port, and it fails quietly: a gate resolves its
inputs to a directory that does not exist, finds nothing to check, and exits 0.
They now call `config.repo_root()`, which asks git.
`PLANET_DIR` (was `TOOLING_DIR`) still means "beside this code" and still
resolves correctly — the data files it addresses, `biomes.toml`,
`sol_overrides.json`, `sol_markers/` and `earth_blocklist.txt`, moved with it.
The rename is so the name stops claiming to be `tooling/`.
"""
@@ -13,19 +13,22 @@ identify names that need hand-refining:
6. Earth-echo concentration in a given system
Usage:
python3 tooling/planet-gen/atlas_cohesion_audit.py
python3 tooling/planet-gen/atlas_cohesion_audit.py --system GJ 144
python3 tooling/planet-gen/atlas_cohesion_audit.py --body GJ144d
reach atlas planet audit
reach atlas planet audit --system GJ 144
reach atlas planet audit --body GJ144d
Decisions: D-191 (atlas pipeline, corridor palettes, markers.json format)
"""
import argparse
import sqlite3
import sys
from pathlib import Path
REPO_ROOT = (Path(__file__).resolve().parent / ".." / "..").resolve()
from tooling.core import config, console
from tooling.core.errors import ReachError
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
CARDINALS = ("North", "South", "East", "West", "Northern", "Southern",
@@ -59,9 +62,9 @@ def get_conn(db_path: Path) -> sqlite3.Connection:
def section(title: str) -> None:
print(f"\n{'='*70}")
print(f" {title}")
print('='*70)
console.out(f"\n{'='*70}")
console.out(f" {title}")
console.out('='*70)
def run_all_features_for_body(conn, body_id: str) -> list[tuple[str, str]]:
@@ -107,10 +110,10 @@ def report_empty_names(conn, system_id: str | None, body_id: str | None) -> None
f"AND (name = '' OR LENGTH(name) < 2)", params
).fetchall()
for r in rows:
print(f" [{table}] {r['body_id']}/{r['local_id']}: '{r['name']}'")
console.out(f" [{table}] {r['body_id']}/{r['local_id']}: '{r['name']}'")
found += 1
if not found:
print(" None found.")
console.out(" None found.")
def report_generic_lazy(conn, system_id: str | None, body_id: str | None) -> None:
@@ -143,11 +146,11 @@ def report_generic_lazy(conn, system_id: str | None, body_id: str | None) -> Non
hits[key] = (r['body_id'], r['local_id'], r['name'], label)
for key, (bid, lid, name, lbl) in sorted(hits.items()):
print(f" [{lbl}] {bid}/{lid}: '{name}'")
console.out(f" [{lbl}] {bid}/{lid}: '{name}'")
found += 1
if not found:
print(" None found.")
console.out(" None found.")
def report_cardinals(conn, system_id: str | None, body_id: str | None) -> None:
@@ -178,12 +181,12 @@ def report_cardinals(conn, system_id: str | None, body_id: str | None) -> None:
)
if not by_body:
print(" None found.")
console.out(" None found.")
return
for bid, entries in sorted(by_body.items()):
print(f" {bid}: {len(entries)} cardinal name(s)")
console.out(f" {bid}: {len(entries)} cardinal name(s)")
for lbl, lid, name in entries:
print(f" [{lbl}] {lid}: '{name}'")
console.out(f" [{lbl}] {lid}: '{name}'")
def report_earth_echoes(conn, system_id: str | None, body_id: str | None) -> None:
@@ -215,12 +218,12 @@ def report_earth_echoes(conn, system_id: str | None, body_id: str | None) -> Non
)
if not by_body:
print(" None found.")
console.out(" None found.")
return
for bid, entries in sorted(by_body.items()):
print(f" {bid}: {len(entries)} earth-echo(s)")
console.out(f" {bid}: {len(entries)} earth-echo(s)")
for lbl, lid, name in entries:
print(f" [{lbl}] {lid}: '{name}'")
console.out(f" [{lbl}] {lid}: '{name}'")
def report_same_body_stem_dupes(conn, system_id: str | None, body_id: str | None) -> None:
@@ -251,18 +254,18 @@ def report_same_body_stem_dupes(conn, system_id: str | None, body_id: str | None
if len(items) > 1:
types = set(i[0] for i in items)
if len(types) > 1: # only flag cross-feature (different types)
print(f" {bid} stem='{stem}':")
console.out(f" {bid} stem='{stem}':")
for lbl, nm in items:
print(f" [{lbl}] '{nm}'")
console.out(f" [{lbl}] '{nm}'")
found += 1
if not found:
print(" None found.")
console.out(" None found.")
def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
section("CROSS-BODY STEM DUPLICATES WITHIN SYSTEM (same corridor)")
if not system_id:
print(" (requires --system; skipped)")
console.out(" (requires --system; skipped)")
return
bodies_q = conn.execute(
@@ -291,13 +294,13 @@ def report_cross_body_stem_dupes(conn, system_id: str | None) -> None:
for stem, hits in sorted(all_names.items()):
bodies_hit = set(h[0] for h in hits)
if len(bodies_hit) > 1:
print(f" stem='{stem}' appears in {len(bodies_hit)} bodies:")
console.out(f" stem='{stem}' appears in {len(bodies_hit)} bodies:")
for bid, label, name in hits:
print(f" {bid} [{label}] '{name}'")
console.out(f" {bid} [{label}] '{name}'")
found += 1
if not found:
print(" None found.")
console.out(" None found.")
def report_summary_score(conn, system_id: str | None, body_id: str | None) -> None:
@@ -317,7 +320,7 @@ def report_summary_score(conn, system_id: str | None, body_id: str | None) -> No
f"SELECT body_id, proper_name, population FROM bodies {where}", params
).fetchall()
print(f" {'body_id':<20} {'name':<20} {'pop':<12} cities rivers oceans mounts pois")
console.out(f" {'body_id':<20} {'name':<20} {'pop':<12} cities rivers oceans mounts pois")
for row in bodies_q:
bid = row["body_id"]
name = row["proper_name"] or ""
@@ -333,24 +336,21 @@ def report_summary_score(conn, system_id: str | None, body_id: str | None) -> No
).fetchone()[0]
counts[key] = n
print(
f" {bid:<20} {name:<20} {pop:<12,} "
console.out(f" {bid:<20} {name:<20} {pop:<12,} "
f"{counts['c']:>5} {counts['r']:>6} {counts['o']:>6} "
f"{counts['m']:>6} {counts['p']:>4}"
)
f"{counts['m']:>6} {counts['p']:>4}")
def main() -> None:
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--system", help="GJ catalog ID (e.g. 'GJ 144')")
parser.add_argument("--body", help="Body ID (e.g. 'GJ144d')")
parser.add_argument("--db", default=str(DB_PATH), help="Path to systems.db")
args = parser.parse_args()
args = parser.parse_args(argv)
db = Path(args.db)
if not db.exists():
print(f"error: database not found at {db}", file=sys.stderr)
sys.exit(1)
raise ReachError(f"database not found at {db}", fix="make regen-db, or point --db at an existing systems.db")
conn = get_conn(db)
@@ -364,12 +364,12 @@ def main() -> None:
if row:
system_id = row["system_id"]
print("\nAtlas Cohesion Audit")
print(f" DB: {db}")
console.out("\nAtlas Cohesion Audit")
console.out(f" DB: {db}")
if system_id:
print(f" System: {system_id}")
console.out(f" System: {system_id}")
if body_id:
print(f" Body: {body_id}")
console.out(f" Body: {body_id}")
report_summary_score(conn, system_id, body_id)
report_empty_names(conn, system_id, body_id)
@@ -381,7 +381,7 @@ def main() -> None:
report_cross_body_stem_dupes(conn, system_id)
conn.close()
print("\nDone.\n")
console.out("\nDone.\n")
if __name__ == "__main__":
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
atlas_common.py shared atlas-DB utilities for the planet-gen importers.
atlas_common.py shared atlas-DB utilities for the atlas planet importers.
Extracted from the retired generate_atlas.py (D-223, #951). The atlas city/road/
river geometry *generator* was retired when authored geometry was dropped in
@@ -20,13 +20,16 @@ Decisions: D-223 (authored content as flavoured name pool), D-191 (atlas index).
import sqlite3
from pathlib import Path
from tooling.core import config
import yaml
# ---------------------------------------------------------------------------
# Paths and grid constants
# ---------------------------------------------------------------------------
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
@@ -10,10 +10,10 @@ Queries atlas_* tables in systems.db and reports on:
5. Top-stem frequency across all named features
Usage:
python3 tooling/planet-gen/atlas_quality_analysis.py [--db server/data/systems.db]
python3 tooling/planet-gen/atlas_quality_analysis.py --system GJ380
python3 tooling/planet-gen/atlas_quality_analysis.py --top-collisions 20
python3 tooling/planet-gen/atlas_quality_analysis.py --body GJ71c
reach atlas planet quality [--db server/data/systems.db]
reach atlas planet quality --system GJ380
reach atlas planet quality --top-collisions 20
reach atlas planet quality --body GJ71c
D-191 §8: markers.json is pixel-space [row, col] against 512×256.
Re-run after any hand-refine pass to verify improvements.
@@ -23,9 +23,11 @@ import argparse
import re
import sqlite3
from collections import Counter, defaultdict
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
from tooling.core import config, console
REPO_ROOT = config.repo_root()
DEFAULT_DB = REPO_ROOT / "server" / "data" / "systems.db"
CARDINAL_RE = re.compile(
@@ -154,26 +156,26 @@ def run_analysis(args):
if args.body:
body_index = {k: v for k, v in body_index.items() if k == args.body}
print("=" * 70)
print("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
print(f"DB: {args.db}")
console.out("=" * 70)
console.out("ATLAS QUALITY ANALYSIS — The Settled Reach (#849/#838)")
console.out(f"DB: {args.db}")
if args.system:
print(f"Filter: system={args.system}")
console.out(f"Filter: system={args.system}")
if args.body:
print(f"Filter: body={args.body}")
print("=" * 70)
console.out(f"Filter: body={args.body}")
console.out("=" * 70)
# --- 1. Cross-body collisions ---
print("\n[ 1. CROSS-BODY NAME COLLISIONS ]")
console.out("\n[ 1. CROSS-BODY NAME COLLISIONS ]")
collisions = cross_body_collisions(conn, limit=args.top_collisions)
for feat_type, rows in collisions.items():
if rows:
print(f"\n {feat_type}:")
console.out(f"\n {feat_type}:")
for name, cnt, bodies in rows:
print(f" '{name}'{cnt} bodies: {bodies[:80]}")
console.out(f" '{name}'{cnt} bodies: {bodies[:80]}")
# --- 2. Per-body quality scores ---
print("\n[ 2. BODY QUALITY SCORES — ranked by collision % ]")
console.out("\n[ 2. BODY QUALITY SCORES — ranked by collision % ]")
reports = []
for bid, info in body_index.items():
names = all_names_by_body.get(bid, [])
@@ -186,27 +188,25 @@ def run_analysis(args):
reports.sort(key=lambda x: -x[2]["colliding_pct"])
print(f"\n {'Body':<28} {'System':<12} {'Corridor':<15} "
console.out(f"\n {'Body':<28} {'System':<12} {'Corridor':<15} "
f"{'Coll%':>6} {'Card%':>6} {'Gen':>4} {'Echo':>4}")
for bid, info, rep in reports[:30]:
print(
f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
console.out(f" {(info['name'] or bid):<28} {info['system_id']:<12} {info['corridor'] or '?':<15} "
f"{rep['colliding_pct']:>6.0%} {rep['cardinal_pct']:>6.0%} "
f"{rep['generic']:>4} {rep['earth_echo']:>4}"
)
f"{rep['generic']:>4} {rep['earth_echo']:>4}")
# --- 3. Stem frequency ---
print("\n[ 3. TOP STEM FREQUENCY (first word of name) ]")
console.out("\n[ 3. TOP STEM FREQUENCY (first word of name) ]")
all_names_flat = [n for names in all_names_by_body.values() for _, n, _ in names]
for stem, cnt in stem_frequency(all_names_flat, top_n=20):
print(f" {stem:<20} {cnt}")
console.out(f" {stem:<20} {cnt}")
# --- 4. Detailed body report (if --body specified) ---
if args.body and args.body in all_names_by_body:
bid = args.body
info = body_index.get(bid, {})
names = all_names_by_body[bid]
print(f"\n[ 4. DETAILED REPORT: {bid} ({info.get('name', '?')}) ]")
console.out(f"\n[ 4. DETAILED REPORT: {bid} ({info.get('name', '?')}) ]")
c = conn.cursor()
for feat_type, name, local_id in sorted(names, key=lambda x: x[0]):
tbl = [t for t, f in FEATURE_TABLES if f == feat_type][0]
@@ -218,29 +218,29 @@ def run_analysis(args):
flag = f" *** COLLISION ×{others}" if others > 0 else ""
cardinal = " [cardinal]" if CARDINAL_RE.search(name) else ""
generic = " [generic]" if GENERIC_RE.search(name) else ""
print(f" {feat_type:<10} {local_id:<12} {name}{flag}{cardinal}{generic}")
console.out(f" {feat_type:<10} {local_id:<12} {name}{flag}{cardinal}{generic}")
# --- 5. Sol gap check ---
print("\n[ 5. SOL SYSTEM GAP CHECK ]")
console.out("\n[ 5. SOL SYSTEM GAP CHECK ]")
c = conn.cursor()
c.execute("SELECT body_id, proper_name, population FROM bodies WHERE system_id='GJ 0' AND inhabited=1")
sol_bodies = c.fetchall()
for bid, bname, pop in sol_bodies:
has_cities = bid in all_names_by_body and any(f == "city" for f, _, _ in all_names_by_body[bid])
status = "HAS DATA" if has_cities else "*** EMPTY — needs authoring"
print(f" {bid:<15} {bname or '?':<20} pop={pop or '?'} {status}")
console.out(f" {bid:<15} {bname or '?':<20} pop={pop or '?'} {status}")
conn.close()
print("\nDone.")
console.out("\nDone.")
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--db", default=str(DEFAULT_DB), help="Path to systems.db")
parser.add_argument("--system", help="Filter to one system (e.g. GJ380)")
parser.add_argument("--body", help="Filter to one body (e.g. GJ71c)")
parser.add_argument("--top-collisions", type=int, default=15, help="Collision list limit")
args = parser.parse_args()
args = parser.parse_args(argv)
run_analysis(args)
@@ -6,13 +6,13 @@ Walks wiki/star-systems/, scaffolds body index.md files where missing,
then generates heightmap + globe + terrain data for every body.
Usage:
python3 batch.py # full run: scaffold + generate
python3 batch.py --scaffold-only # just create body index.md files
python3 batch.py --generate-only # just render (bodies must exist)
python3 batch.py --system GJ-144 # single system
python3 batch.py --system GJ-144 --body GJ144d # single body
python3 batch.py --overrides sol.json # per-body overrides
python3 batch.py --dry-run # validate data, don't generate
reach atlas planet batch # full run: scaffold + generate
reach atlas planet batch --scaffold-only # just create body index.md files
reach atlas planet batch --generate-only # just render (bodies must exist)
reach atlas planet batch --system GJ-144 # single system
reach atlas planet batch --system GJ-144 --body GJ144d # single body
reach atlas planet batch --overrides sol.json # per-body overrides
reach atlas planet batch --dry-run # validate data, don't generate
Skips:
- GJ-0 (Sol) manual overrides required, use --system GJ-0 explicitly
@@ -26,26 +26,31 @@ Error handling:
import argparse
import json
import os
import sys
import time
import traceback
from datetime import datetime
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
# Venv bootstrap
TOOLING_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import yaml
import numpy as np
from body_definition_parser import parse_system
from planet_simulation import simulate
from render_heightmap import render_heightmap
from tooling.domains.atlas.planet.body_definition_parser import parse_system
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
# Systems to skip in batch mode (require manual handling)
SKIP_SYSTEMS = {"GJ-0"}
@@ -165,7 +170,7 @@ def _scaffold_system(system_dir: Path, overrides: dict) -> list:
if not index_md.exists():
return []
from scaffold_bodies import _body_to_frontmatter, _body_prose
from tooling.domains.atlas.planet.scaffold_bodies import _body_to_frontmatter, _body_prose
body_defs = parse_system(str(index_md), overrides=overrides)
bodies_dir = system_dir / "bodies"
@@ -247,7 +252,7 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
_save_img(hmap_img, body_dir / "heightmap.png")
# Globe — atomic write
from planet_renderer import render_globe
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(bd, terrain, size=globe_size)
_save_img(globe_img, body_dir / "globe.png")
@@ -262,14 +267,14 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
np.savez_compressed(str(body_dir / "terrain.npz"), **save_dict)
# Markers
from generate import _build_markers
from tooling.domains.atlas.planet.generate import _build_markers
markers = _build_markers(bd, terrain)
_save(markers, body_dir / "markers.json",
lambda m, p: Path(p).write_text(json.dumps(m, indent=2)))
elapsed = time.time() - t0
kind = "gas" if is_gas else f"land={int((~terrain['surface_water']).sum())}"
print(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
console.event(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
return "generated"
@@ -277,7 +282,7 @@ def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Batch planet generation — all systems unattended")
parser.add_argument("--system", help="Process only this system (dir name, e.g. GJ-144)")
@@ -293,15 +298,14 @@ def main():
help="Run N random bodies twice and verify identical output")
parser.add_argument("--heightmap-size", default="1024x512")
parser.add_argument("--globe-size", type=int, default=512)
args = parser.parse_args()
args = parser.parse_args(argv)
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
wiki_systems = WORKTREE_ROOT / "wiki" / "star-systems"
if not wiki_systems.exists():
print(f"error: {wiki_systems} not found", file=sys.stderr)
sys.exit(1)
raise ReachError(f"{wiki_systems} not found", fix="run from a settled-reach checkout — make reach-repoint")
overrides = {}
if args.overrides:
@@ -312,8 +316,7 @@ def main():
if args.system:
system_dirs = [wiki_systems / args.system]
if not system_dirs[0].exists():
print(f"error: system {args.system} not found", file=sys.stderr)
sys.exit(1)
raise ReachError(f"system {args.system} not found", fix="--system takes the wiki directory name, e.g. GJ-1002 (see wiki/star-systems/)")
else:
system_dirs = sorted([
d for d in wiki_systems.iterdir()
@@ -334,19 +337,18 @@ def main():
total_invalid = 0
error_rate_threshold = 0.50
print("\n Planet Generator — Batch Mode")
print(f" Systems: {len(system_dirs)}")
print(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
console.event(" Planet Generator — Batch Mode")
console.event(f" Systems: {len(system_dirs)}")
console.event(f" Heightmap: {hmap_w}×{hmap_h} Globe: {args.globe_size}×{args.globe_size}")
if args.dry_run:
print(" Mode: DRY RUN (validation only)")
print()
console.event(" Mode: DRY RUN (validation only)")
for system_dir in system_dirs:
system_id = system_dir.name
# Skip Sol in batch mode (needs manual overrides)
if system_id in SKIP_SYSTEMS and not args.system:
print(f" {system_id} — skipped (manual)")
console.event(f" {system_id} — skipped (manual)")
continue
system_name = _read_system_name(system_dir / "index.md")
@@ -364,7 +366,7 @@ def main():
except Exception:
n_bodies = 0
print(f" {system_id}{system_name} ({n_bodies} bodies)")
console.event(f" {system_id}{system_name} ({n_bodies} bodies)")
# ── Scaffold ─────────────────────────────────────────────────────
if not args.generate_only and not args.dry_run:
@@ -373,10 +375,10 @@ def main():
if created:
total_scaffolded += len(created)
for bid in created:
print(f" scaffolded {bid}")
console.event(f" scaffolded {bid}")
except Exception as e:
tb = traceback.format_exc()
print(f" SCAFFOLD ERROR: {e}")
console.event(f" SCAFFOLD ERROR: {e}")
_log_error(system_id, "*", str(e), tb)
# ── Validate / Generate ──────────────────────────────────────────
@@ -412,12 +414,12 @@ def main():
errors = _validate_body_def(bd, body_dir)
if errors:
total_invalid += 1
print(f" {body_id:20s} ({body_name:20s}) INVALID")
console.event(f" {body_id:20s} ({body_name:20s}) INVALID")
for err in errors:
print(f" - {err}")
console.event(f" - {err}")
else:
total_valid += 1
print(f" {body_id:20s} ({body_name:20s}) ok")
console.event(f" {body_id:20s} ({body_name:20s}) ok")
continue
if args.scaffold_only:
@@ -437,30 +439,30 @@ def main():
except Exception as e:
total_errors += 1
tb = traceback.format_exc()
print(f" {body_id:20s} ({body_name:20s}) ERROR: {e}")
console.event(f" {body_id:20s} ({body_name:20s}) ERROR: {e}")
_log_error(system_id, body_id, str(e), tb)
# Circuit breaker: abort if error rate is too high
if total_attempted >= 10 and total_errors / total_attempted > error_rate_threshold:
print(f"\n ABORT: error rate {total_errors}/{total_attempted} "
console.event(f" ABORT: error rate {total_errors}/{total_attempted} "
f"({total_errors/total_attempted*100:.0f}%) exceeds "
f"{error_rate_threshold*100:.0f}% threshold")
print(f" Check {LOG_PATH} for details")
console.event(f" Check {LOG_PATH} for details")
sys.exit(1)
elapsed = time.time() - t_total
print(f"\n Batch complete: {elapsed:.0f}s")
console.event(f" Batch complete: {elapsed:.0f}s")
if args.dry_run:
print(f" valid: {total_valid}")
print(f" invalid: {total_invalid}")
console.event(f" valid: {total_valid}")
console.event(f" invalid: {total_invalid}")
else:
print(f" scaffolded: {total_scaffolded}")
print(f" generated: {total_generated}")
print(f" skipped: {total_skipped}")
print(f" errors: {total_errors}")
console.event(f" scaffolded: {total_scaffolded}")
console.event(f" generated: {total_generated}")
console.event(f" skipped: {total_skipped}")
console.event(f" errors: {total_errors}")
if total_errors > 0:
print(f" error log: {LOG_PATH}")
console.event(f" error log: {LOG_PATH}")
# ── Determinism verification ─────────────────────────────────────────
if args.verify_determinism > 0:
@@ -475,7 +477,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
import tempfile
import shutil
print(f"\n Determinism verification — {n_samples} samples")
console.event(f" Determinism verification — {n_samples} samples")
# Collect all body dirs that have been generated
all_body_dirs = []
@@ -487,7 +489,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
all_body_dirs.append(bd)
if not all_body_dirs:
print(" no generated bodies to verify")
console.event(" no generated bodies to verify")
return
rng = np.random.default_rng(42)
@@ -514,7 +516,7 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
try:
_generate_body_from_dir(tmp_body, hmap_w, hmap_h, globe_size, force=True)
except Exception as e:
print(f" {body_id}: generation failed — {e}")
console.event(f" {body_id}: generation failed — {e}")
shutil.rmtree(tmp_dir)
failed += 1
continue
@@ -527,13 +529,13 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
if not orig.exists() and not rerun.exists():
continue
if not orig.exists() or not rerun.exists():
print(f" {body_id}: {fname} — missing in {'original' if not orig.exists() else 'rerun'}")
console.event(f" {body_id}: {fname} — missing in {'original' if not orig.exists() else 'rerun'}")
all_match = False
continue
h1 = hashlib.sha256(orig.read_bytes()).hexdigest()[:16]
h2 = hashlib.sha256(rerun.read_bytes()).hexdigest()[:16]
if h1 != h2:
print(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
console.event(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
all_match = False
if all_match:
@@ -543,9 +545,14 @@ def _verify_determinism(wiki_systems: Path, n_samples: int,
shutil.rmtree(tmp_dir)
print(f" passed: {passed} failed: {failed}")
console.event(f" passed: {passed} failed: {failed}")
if failed > 0:
print(" WARNING: non-deterministic output detected!")
# Was a printed warning with exit 0 — a determinism check that could not
# fail (T-1288). The 271-body bake is only re-runnable because this holds.
raise ReachError(
f"non-deterministic output: {failed} of {passed + failed} bodies differ on re-run",
fix="reach atlas planet batch --verify-determinism 1, then diff the MISMATCH files above",
)
if __name__ == "__main__":
@@ -6,7 +6,7 @@ parameters. All three pipeline modules import from here instead of
maintaining their own hardcoded tables.
Usage:
from biome_config import (
from tooling.domains.atlas.planet.biome_config import (
WHITTAKER_TABLE, CLASS_T_BAND, BIOME_PALETTE,
STAR_TINTS, ATMO_COLORS, GAS_PALETTES,
EXOTIC_CLASSES, CRATER_SCALING, RIVER_RGB, COAST_RGB,
@@ -21,10 +21,10 @@ Field resolution order (highest wins):
5. randomised (seeded, within planet-class constraints)
Usage:
python3 body_definition_parser.py path/to/index.md [--out-dir ./defs]
python -m tooling.domains.atlas.planet.body_definition_parser path/to/index.md [--out-dir ./defs]
# With overrides (e.g. Sol)
python3 body_definition_parser.py sol/index.md --overrides sol_overrides.json
python -m tooling.domains.atlas.planet.body_definition_parser sol/index.md --overrides sol_overrides.json
Override file format:
{
@@ -206,7 +206,8 @@ CLASS_OBLATENESS = {
}
# Gas giant band palettes available
from biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
from tooling.domains.atlas.planet.biome_config import GAS_PALETTE_SELECTION as GAS_PALETTES
from tooling.core import console
# planet_class → cloud coverage base range
CLASS_CLOUD = {
@@ -855,4 +856,4 @@ if __name__ == "__main__":
defs = parse_system(args.md_file, overrides=overrides, out_dir=args.out_dir)
if args.print:
print(json.dumps(defs, indent=2))
console.out(json.dumps(defs, indent=2))
@@ -20,21 +20,26 @@ Optional (spike/review only):
import argparse
import json
import os
import sys
import time
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
from tooling.core import config, console
from tooling.core.errors import ReachError
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
from planet_simulation import simulate
from render_heightmap import render_heightmap
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
def _build_markers(body_def: dict, terrain: dict) -> dict:
@@ -131,7 +136,7 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
planet_class = body_def.get("planet_class", "unknown")
name = body_def.get("name") or body_id
print(f"\n {body_id} ({name}) — {planet_class}")
console.event(f" {body_id} ({name}) — {planet_class}")
t0 = time.time()
@@ -141,9 +146,9 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
t_sim = time.time()
if is_gas:
print(f" simulate: gas giant ({t_sim - t0:.1f}s)")
console.event(f" simulate: gas giant ({t_sim - t0:.1f}s)")
else:
print(f" simulate: {t_sim - t0:.1f}s "
console.event(f" simulate: {t_sim - t0:.1f}s "
f"sea={terrain['sea_level']:.3f} "
f"land={int((~terrain['surface_water']).sum())}")
@@ -163,20 +168,20 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
render_mode=render_mode, chrome=True)
chrome_path = f"/tmp/{body_id}_heightmap_chrome.png"
hmap_chrome.save(chrome_path)
print(f" chrome: {chrome_path}")
console.event(f" chrome: {chrome_path}")
t_hmap = time.time()
print(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
console.event(f" heightmap: {t_hmap - t_sim:.1f}s {hmap_w}×{hmap_h}")
# ── 3. Render globe ──────────────────────────────────────────────────
try:
from planet_renderer import render_globe
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(body_def, terrain, size=globe_size)
globe_img.save(os.path.join(body_dir, "globe.png"))
t_globe = time.time()
print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}×{globe_size}")
except Exception as e:
print(f" globe: FAILED — {e}")
console.event(f" globe: FAILED — {e}")
t_globe = time.time()
# ── 4. Write data files ──────────────────────────────────────────────
@@ -198,10 +203,10 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
json.dump(markers, f, indent=2)
elapsed = time.time() - t0
print(f" total: {elapsed:.1f}s → {body_dir}/")
console.event(f" total: {elapsed:.1f}s → {body_dir}/")
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Planet generator — heightmap + globe from body definitions")
@@ -227,28 +232,27 @@ def main():
parser.add_argument("--chrome", action="store_true",
help="Also render heightmap with title/legend (review only, not shipped)")
args = parser.parse_args()
args = parser.parse_args(argv)
# Parse heightmap size
try:
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
except ValueError:
print(f"error: invalid heightmap size '{args.heightmap_size}'", file=sys.stderr)
sys.exit(1)
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
# ── Collect body definitions ─────────────────────────────────────────
body_defs = []
if args.system:
# Read from system index.md → parse bodies table
from body_definition_parser import parse_system
from tooling.domains.atlas.planet.body_definition_parser import parse_system
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
body_defs = parse_system(args.system, overrides=overrides)
print(f"System: {args.system}{len(body_defs)} bodies")
console.event(f"System: {args.system}{len(body_defs)} bodies")
elif args.body_def:
input_path = args.body_def
if input_path.endswith(".json"):
@@ -269,15 +273,11 @@ def main():
if args.output_dir == ".":
args.output_dir = str(Path(input_path).parent)
else:
print(f"error: {input_path} frontmatter missing 'id' or 'planet_class'",
file=sys.stderr)
sys.exit(1)
raise ReachError(f"{input_path} frontmatter missing 'id' or 'planet_class'", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
else:
print(f"error: {input_path} has no YAML frontmatter", file=sys.stderr)
sys.exit(1)
raise ReachError(f"{input_path} has no YAML frontmatter", fix="re-scaffold the system: reach atlas planet scaffold <system index>")
else:
print(f"error: unrecognized input format: {input_path}", file=sys.stderr)
sys.exit(1)
raise ReachError(f"unrecognized input format: {input_path}", fix="pass a body definition .json or a body index.md")
else:
parser.error("Provide a body_def (.json or .md) or --system index.md")
@@ -288,7 +288,7 @@ def main():
args.render_mode, args.output_dir, chrome=args.chrome)
elapsed = time.time() - t_total
print(f"\n All done: {len(body_defs)} bodies in {elapsed:.1f}s")
console.event(f" All done: {len(body_defs)} bodies in {elapsed:.1f}s")
if __name__ == "__main__":
@@ -18,9 +18,9 @@ only its display file is renamed.
No systems.db writes: the PNG is the store (the atlas_body_heightmaps BLOB
table is dropped). Run with uv (numpy/scipy/Pillow):
uv run python tooling/planet-gen/import_heightmaps.py # full bake
uv run python tooling/planet-gen/import_heightmaps.py --limit 3 # smoke test
uv run python tooling/planet-gen/import_heightmaps.py --dry-run
reach atlas planet import-heightmaps # full bake
reach atlas planet import-heightmaps --limit 3 # smoke test
reach atlas planet import-heightmaps --dry-run
Exit codes: 0 = completed (possibly with per-body errors), 1 = fatal.
"""
@@ -32,17 +32,20 @@ import sys
import time
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
sys.path.insert(0, str(TOOLING_DIR))
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import simulate # noqa: E402
from tooling.domains.atlas.planet.render_heightmap import render_heightmap # noqa: E402
from body_definition_parser import parse_system # noqa: E402
from planet_simulation import simulate # noqa: E402
from render_heightmap import render_heightmap # noqa: E402
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_SYSTEMS = REPO_ROOT / "wiki" / "star-systems"
@@ -89,7 +92,7 @@ def bake_body(bd: dict, body_dir: Path, dry_run: bool) -> dict:
return {"status": "baked", "shape": elevation.shape, "sea_level": sea_level}
def main() -> None:
def main(argv: list[str] | None = None) -> None:
ap = argparse.ArgumentParser(description="Bake canonical heightmap/reliefmap assets (#963)")
ap.add_argument("--db", default=str(DB_PATH))
ap.add_argument("--body", help="Bake only this body_id")
@@ -100,17 +103,16 @@ def main() -> None:
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
print("\n Heightmap bake (#963, D-202 amended)")
console.event(" Heightmap bake (#963, D-202 amended)")
if args.dry_run:
print(" Mode: DRY RUN (no files written)")
console.event(" Mode: DRY RUN (no files written)")
# 1. Universal rename of the legacy color heightmap.png → reliefmap.png.
if not args.skip_rename:
n_renamed = rename_legacy_heightmaps(args.dry_run)
print(f" Renamed legacy heightmap.png → reliefmap.png: {n_renamed} bodies")
console.event(f" Renamed legacy heightmap.png → reliefmap.png: {n_renamed} bodies")
# 2. Bake non-Sol inhabited bodies.
conn = sqlite3.connect(str(db_path))
@@ -125,7 +127,7 @@ def main() -> None:
if args.limit:
rows = rows[: args.limit]
print(f" Baking {len(rows)} non-Sol inhabited bodies\n")
console.event(f" Baking {len(rows)} non-Sol inhabited bodies\n")
parsed: dict[str, list] = {}
t0 = time.time()
@@ -137,12 +139,12 @@ def main() -> None:
defs = parsed.get(sys_index) or parse_system(sys_index)
parsed[sys_index] = defs
except Exception as exc: # noqa: BLE001
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR parse_system: {exc}")
n_err += 1
continue
bd = next((d for d in defs if d.get("id") == body_id), None)
if bd is None:
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: not in system defs")
n_err += 1
continue
ts = time.time()
@@ -151,16 +153,16 @@ def main() -> None:
if res["status"] == "baked":
n_baked += 1
if (i + 1) % 25 == 0 or args.limit:
print(f" [{i+1}/{len(rows)}] {body_id:18s} baked {res['shape']} "
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} baked {res['shape']} "
f"sea={res['sea_level']:.3f} ({dt:.1f}s)")
elif res["status"] == "gas_giant":
n_gas += 1
else:
n_err += 1
print(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
console.event(f" [{i+1}/{len(rows)}] {body_id:18s} ERROR: {res.get('message','')}")
print(f"\n Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
print(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
console.event(f" Done in {time.time()-t0:.0f}s — baked={n_baked} gas_giant={n_gas} errors={n_err}")
console.event(" Stage: git add wiki/star-systems (reliefmap.png renames + heightmap.png)")
if n_err:
sys.exit(0) # per-body errors are non-fatal; reported above
@@ -32,10 +32,10 @@ Incremental: bodies that already have rows in atlas_province_boundaries are skip
unless --force is passed.
Usage:
tooling/planet-gen/import_province_boundaries.py
tooling/planet-gen/import_province_boundaries.py --body GJ380c
tooling/planet-gen/import_province_boundaries.py --force
tooling/planet-gen/import_province_boundaries.py --dry-run
reach atlas planet import-provinces
reach atlas planet import-provinces --body GJ380c
reach atlas planet import-provinces --force
reach atlas planet import-provinces --dry-run
Exit codes:
0 completed
@@ -44,22 +44,25 @@ Exit codes:
import argparse
import json
import sys
import time
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
REPO_ROOT = (TOOLING_DIR / ".." / "..").resolve()
from tooling.core import config, console
from tooling.core.errors import ReachError
_venv_python = REPO_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
import os
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
import sqlite3
from atlas_common import (
from tooling.domains.atlas.planet.atlas_common import (
DB_PATH,
ensure_atlas_schema,
query_inhabited_bodies,
@@ -441,7 +444,7 @@ def import_body_provinces(
if verbose:
areas = [f"{b['basin_id']}:{b['area_pct']:.1%}" for b in basins]
print(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
console.event(f" {body_id}: {len(basins)} basins — {', '.join(areas)}")
if not dry_run:
with conn: # per-body transaction (BEGIN/COMMIT): rolls back this body on exception, keeps prior commits
@@ -460,7 +463,7 @@ def import_body_provinces(
return {"status": "imported", "imported": len(basins)}
def main() -> None:
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Pre-compute province boundaries from watershed analysis (D-205, #907)"
)
@@ -472,20 +475,18 @@ def main() -> None:
help="Analyse without writing to DB")
parser.add_argument("--verbose", action="store_true",
help="Print per-body detail")
args = parser.parse_args()
args = parser.parse_args(argv)
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found", file=sys.stderr)
sys.exit(1)
raise ReachError(f"{db_path} not found", fix="make regen-db, or point --db at an existing systems.db")
print("\n Province Boundary Import (#907)")
print(f" DB: {db_path}")
console.event(" Province Boundary Import (#907)")
console.event(f" DB: {db_path}")
if args.dry_run:
print(" Mode: DRY RUN (no DB writes)")
console.event(" Mode: DRY RUN (no DB writes)")
if args.force:
print(" Force: enabled (will overwrite existing rows)")
print()
console.event(" Force: enabled (will overwrite existing rows)")
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA foreign_keys=ON")
@@ -495,12 +496,10 @@ def main() -> None:
if args.body:
bodies = [b for b in bodies if b["body_id"] == args.body]
if not bodies:
print(f"error: body '{args.body}' not found or has no terrain_reference",
file=sys.stderr)
conn.close()
sys.exit(1)
raise ReachError(f"body '{args.body}' not found or has no terrain_reference", fix="reach atlas planet terrain-reference, then retry")
print(f" {len(bodies)} inhabited bodies with terrain_reference\n")
console.event(f" {len(bodies)} inhabited bodies with terrain_reference\n")
t_total = time.time()
n_imported = 0
@@ -518,28 +517,28 @@ def main() -> None:
if status == "imported":
n_imported += 1
print(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} {result['imported']} basins ({elapsed:.1f}s)")
elif status == "skipped":
n_skipped += 1
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} skipped ({result['message']})")
elif status == "no_heightmap":
n_no_hmap += 1
if args.verbose:
print(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} no heightmap — skipping")
elif status == "error":
n_errors += 1
print(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
console.event(f" [{i+1}/{len(bodies)}] {body_id:20s} ERROR: {result.get('message', '')}")
conn.close()
elapsed_total = time.time() - t_total
print(f"\n Done in {elapsed_total:.1f}s")
print(f" imported={n_imported} skipped={n_skipped} "
console.event(f" Done in {elapsed_total:.1f}s")
console.event(f" imported={n_imported} skipped={n_skipped} "
f"no_heightmap={n_no_hmap} errors={n_errors}")
if n_errors > 0:
print(f"\n {n_errors} error(s) — check output above", file=sys.stderr)
raise ReachError(f"{n_errors} error(s) — check output above", fix="re-run the failing body alone: reach atlas planet import-provinces --body <id> --verbose")
if __name__ == "__main__":
@@ -25,7 +25,7 @@ Outputs:
PIL Image (RGBA, 2048×2048) caller saves as PNG
Usage:
from planet_renderer import render_globe
from tooling.domains.atlas.planet.planet_renderer import render_globe
img = render_globe(body_def, terrain=None)
img.save("myplanet.png")
@@ -43,13 +43,14 @@ import math
import numpy as np
from PIL import Image
from biome_config import (
from tooling.domains.atlas.planet.biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
STAR_TINTS as _STAR_TINTS_CFG,
ATMO_COLORS as _ATMO_COLORS_CFG,
GAS_PALETTES as _GAS_PALETTES_CFG,
MAX_BIOME_ID,
)
from tooling.core import console
# ---------------------------------------------------------------------------
# Output resolution
@@ -969,7 +970,7 @@ if __name__ == "__main__":
out = os.path.join(out_dir, f"{bd['id']}.png")
img.save(out, format="PNG")
dt = time.time() - t0
print(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
console.event(f" {bd['id']:30s} {qa_size}×{qa_size} {dt:.1f}s")
paths.append(out)
print(f"\nDone. {len(paths)} planets rendered at {qa_size}px.")
console.event(f"Done. {len(paths)} planets rendered at {qa_size}px.")
@@ -35,9 +35,10 @@ import math
import numpy as np
from scipy.ndimage import gaussian_filter
from biome_config import (
from tooling.domains.atlas.planet.biome_config import (
WHITTAKER_TABLE, CLASS_T_BAND, EXOTIC_CLASSES, CRATER_SCALING,
)
from tooling.core import console
log = logging.getLogger(__name__)
# Canonical heightmap grid (D-202 amended, #963): bumped to 1024×512 so the
@@ -902,7 +903,7 @@ if __name__ == "__main__":
from PIL import Image
if len(sys.argv) < 2:
print("Usage: python3 planet_simulation.py body_def.json [--save-grids]")
console.event("Usage: python -m tooling.domains.atlas.planet.planet_simulation body_def.json [--save-grids]")
sys.exit(1)
with open(sys.argv[1]) as f:
@@ -910,21 +911,21 @@ if __name__ == "__main__":
save_grids = "--save-grids" in sys.argv
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
console.event(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
if not terrain:
print("Gas giant — no terrain simulation.")
console.event("Gas giant — no terrain simulation.")
sys.exit(0)
dt = time.time() - t0
print(f"Done in {dt:.1f}s")
print(f" sea_level: {terrain['sea_level']:.3f}")
print(f" land cells: {(~terrain['surface_water']).sum()}")
console.event(f"Done in {dt:.1f}s")
console.event(f" sea_level: {terrain['sea_level']:.3f}")
console.event(f" land cells: {(~terrain['surface_water']).sum()}")
ids, counts = np.unique(terrain['biome'], return_counts=True)
print(f" biomes: {list(zip(ids.tolist(), counts.tolist()))}")
console.event(f" biomes: {list(zip(ids.tolist(), counts.tolist()))}")
if save_grids:
out = f"/tmp/{bd['id']}_grids"
@@ -933,4 +934,4 @@ if __name__ == "__main__":
arr = terrain[name]
Image.fromarray((arr * 255).astype("uint8"), "L").save(
f"{out}/{name}.png")
print(f"Grids saved → {out}/")
console.event(f"Grids saved → {out}/")
@@ -13,9 +13,9 @@ Bodies with missing heightmaps are logged to stdout for remediation.
This is the prerequisite for the atlas importers (import_heightmaps.py, #901).
Usage:
python3 tooling/planet-gen/populate_terrain_reference.py
python3 tooling/planet-gen/populate_terrain_reference.py --dry-run
python3 tooling/planet-gen/populate_terrain_reference.py --db path/to/systems.db
reach atlas planet terrain-reference
reach atlas planet terrain-reference --dry-run
reach atlas planet terrain-reference --db path/to/systems.db
Decisions: D-191 (atlas pipeline prerequisites)
"""
@@ -24,8 +24,11 @@ import argparse
import sqlite3
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = (SCRIPT_DIR / ".." / "..").resolve()
from tooling.core import config, console
PLANET_DIR = Path(__file__).resolve().parent
REPO_ROOT = config.repo_root()
DB_PATH = REPO_ROOT / "server" / "data" / "systems.db"
WIKI_DIR = REPO_ROOT / "wiki" / "star-systems"
@@ -53,7 +56,7 @@ def relative_terrain_reference(system_id: str, body_id: str) -> str:
return f"wiki/star-systems/{system_slug(system_id)}/bodies/{body_id}/heightmap.png"
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Populate terrain_reference column in systems.db bodies table"
)
@@ -63,11 +66,11 @@ def main():
action="store_true",
help="Report without writing changes",
)
args = parser.parse_args()
args = parser.parse_args(argv)
db_path = Path(args.db)
if not db_path.exists():
print(f"error: {db_path} not found")
console.event(f"error: {db_path} not found")
raise SystemExit(1)
conn = sqlite3.connect(str(db_path))
@@ -81,11 +84,11 @@ def main():
ORDER BY b.system_id, b.body_id
""").fetchall()
print("\n terrain_reference population pass")
print(f" DB: {db_path}")
console.event(" terrain_reference population pass")
console.event(f" DB: {db_path}")
if args.dry_run:
print(" Mode: DRY RUN")
print(f"\n {len(rows)} bodies with NULL terrain_reference\n")
console.event(" Mode: DRY RUN")
console.event(f" {len(rows)} bodies with NULL terrain_reference\n")
found = []
missing = []
@@ -99,16 +102,15 @@ def main():
# Report missing heightmaps before writing — helps flag gaps early.
if missing:
print(f" MISSING heightmaps ({len(missing)} bodies — no update for these):")
console.event(f" MISSING heightmaps ({len(missing)} bodies — no update for these):")
for body_id, system_id, path in missing:
print(f" {body_id} ({system_id}) → {path}")
print()
console.event(f" {body_id} ({system_id}) → {path}")
if found:
print(f" Updating {len(found)} bodies with terrain_reference:")
console.event(f" Updating {len(found)} bodies with terrain_reference:")
for body_id, system_id in found:
ref = relative_terrain_reference(system_id, body_id)
print(f" {body_id} ({system_id}) → {ref}")
console.event(f" {body_id} ({system_id}) → {ref}")
if not args.dry_run:
conn.execute(
"UPDATE bodies SET terrain_reference = ? WHERE body_id = ?",
@@ -117,22 +119,22 @@ def main():
if not args.dry_run:
conn.commit()
print(f"\n Committed {len(found)} terrain_reference updates.")
console.event(f" Committed {len(found)} terrain_reference updates.")
else:
print("\n Dry run — no changes written.")
console.event(" Dry run — no changes written.")
conn.close()
# Summary
print("\n Summary:")
print(f" Updated: {len(found)}")
print(f" Missing: {len(missing)}")
print(f" Total: {len(rows)}\n")
console.event(" Summary:")
console.event(f" Updated: {len(found)}")
console.event(f" Missing: {len(missing)}")
console.event(f" Total: {len(rows)}\n")
if missing:
print(f" Action required: generate heightmaps for {len(missing)} bodies "
console.event(f" Action required: generate heightmaps for {len(missing)} bodies "
f"before running the atlas importers (#901).")
print(" Use: make generate-terrain (or run generate.py per body)\n")
console.event(" Use: make generate-terrain (or run generate.py per body)\n")
if __name__ == "__main__":
@@ -27,9 +27,9 @@ Geographic only. No settlements, roads, or cultural data.
Those live in a separate JSON sidecar and are overlaid by the atlas app.
Usage:
from render_heightmap import render_heightmap
from planet_simulation import simulate
from body_definition_parser import parse_system
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.body_definition_parser import parse_system
defs = parse_system("index.md")
terrain = simulate(defs[0])
@@ -41,12 +41,13 @@ import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy.ndimage import binary_dilation
from biome_config import (
from tooling.domains.atlas.planet.biome_config import (
BIOME_PALETTE as _BIOME_PALETTE_CFG,
RIVER_RGB as _RIVER_RGB_CFG,
COAST_RGB as _COAST_RGB_CFG,
build_biome_rgb,
)
from tooling.core import console
# ---------------------------------------------------------------------------
# Output resolution
@@ -423,7 +424,7 @@ if __name__ == "__main__":
import time
if len(sys.argv) < 2:
print("Usage: python3 render_heightmap.py body_def.json [--large]")
console.event("Usage: python -m tooling.domains.atlas.planet.render_heightmap body_def.json [--large]")
sys.exit(1)
with open(sys.argv[1]) as f:
@@ -433,23 +434,23 @@ if __name__ == "__main__":
large = "--large" in sys.argv
w, h = (4096, 2048) if large else (OUT_W, OUT_H)
from planet_simulation import simulate
from tooling.domains.atlas.planet.planet_simulation import simulate
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
console.event(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
sim_t = time.time() - t0
if not terrain:
print("Gas giant — no heightmap.")
console.event("Gas giant — no heightmap.")
sys.exit(0)
print(f"Rendering heightmap {w}×{h}")
console.event(f"Rendering heightmap {w}×{h}")
t1 = time.time()
img = render_heightmap(bd, terrain, out_w=w, out_h=h)
ren_t = time.time() - t1
out = f"/mnt/user-data/outputs/{bd['id']}_heightmap.png"
img.save(out, format="PNG")
print(f"Saved: {out}")
print(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
console.event(f"Saved: {out}")
console.event(f" simulate={sim_t:.1f}s render={ren_t:.1f}s total={sim_t+ren_t:.1f}s")
+261
View File
@@ -0,0 +1,261 @@
"""Transport for `atlas planet` — args in, delegate, format out.
Every verb here declares its options explicitly rather than forwarding an
opaque argument list. That is deliberate and it is the more expensive option:
the underlying modules already parse their own arguments, so this restates ten
surfaces that exist elsewhere.
It is worth it because the primary user of reach is an agent (D-263), and an
agent discovers a command by reading its `--help`. A passthrough would make
`reach atlas planet generate --help` describe nothing, and the real surface
would only be findable by reading the module — which is the fragmentation the
whole CLI exists to end.
The cost is a second place that can drift. `tooling/test_planet_router.py`
closes that: it hands every declared option to the module's own parser and
fails if the parser does not recognise it.
"""
from __future__ import annotations
from pathlib import Path
import typer
from tooling.core import cli
from tooling.core.command import command
app = cli.domain("planet", "Bodies — scaffold, generate, import and audit.")
@app.callback()
def _rung() -> None:
"""Keeps `planet` a group (Typer collapses a single-command app)."""
def _flags(**pairs: object) -> list[str]:
"""Turn declared options into the argv the module's parser expects.
None means "not given" and is dropped, so the module's own defaults stay
authoritative — restating them here would be a second source of truth for
every default in the domain. A list repeats the flag, for the parsers that
declare `action="append"`.
"""
argv: list[str] = []
for name, value in pairs.items():
flag = "--" + name.replace("_", "-")
if value is None or value is False:
continue
if value is True:
argv.append(flag)
elif isinstance(value, (list, tuple)):
for item in value:
argv += [flag, str(item)]
else:
argv += [flag, str(value)]
return argv
# --- generation -----------------------------------------------------------
@app.command("generate")
@command
def generate(
body_def: Path = typer.Argument(..., help="Body definition JSON."),
system: str = typer.Option(None, "--system", help="System id to generate for."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
output_dir: Path = typer.Option(None, "--output-dir", help="Where to write output."),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
render_mode: str = typer.Option(None, "--render-mode", help="Renderer mode."),
chrome: bool = typer.Option(False, "--chrome", help="Draw chrome on the render."),
) -> None:
"""Generate one body — simulate, render the heightmap, write the globe."""
from tooling.domains.atlas.planet import generate as impl
impl.main([
str(body_def),
*_flags(
system=system,
overrides=overrides,
output_dir=output_dir,
heightmap_size=heightmap_size,
globe_size=globe_size,
render_mode=render_mode,
chrome=chrome,
),
])
@app.command("batch")
@command
def batch(
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
scaffold_only: bool = typer.Option(False, "--scaffold-only", help="Scaffold, do not generate."),
generate_only: bool = typer.Option(False, "--generate-only", help="Generate, do not scaffold."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
force: bool = typer.Option(False, "--force", help="Regenerate what already exists."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan, write nothing."),
verify_determinism: int = typer.Option(
None, "--verify-determinism", metavar="N",
help="Generate N random bodies twice and verify the output is identical.",
),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
) -> None:
"""Generate every system unattended — the long one; consider --detach."""
from tooling.domains.atlas.planet import batch as impl
impl.main(_flags(
system=system,
body=body,
scaffold_only=scaffold_only,
generate_only=generate_only,
overrides=overrides,
force=force,
dry_run=dry_run,
verify_determinism=verify_determinism,
heightmap_size=heightmap_size,
globe_size=globe_size,
))
@app.command("scaffold")
@command
def scaffold(
system_index: Path = typer.Argument(..., help="System index JSON."),
overrides: Path = typer.Option(None, "--overrides", help="Override JSON."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report the plan, write nothing."),
) -> None:
"""Write body definitions for a system, ready for `generate`."""
from tooling.domains.atlas.planet import scaffold_bodies as impl
impl.main([str(system_index), *_flags(overrides=overrides, dry_run=dry_run)])
# --- imports into the atlas DB -------------------------------------------
@app.command("import-heightmaps")
@command
def import_heightmaps(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
limit: int = typer.Option(None, "--limit", help="Stop after N bodies."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
skip_rename: bool = typer.Option(False, "--skip-rename", help="Do not rename source files."),
) -> None:
"""Load heightmap grids into atlas_body_heightmaps.
A one-time build import, deliberately NOT part of `make regen-db` and not
stamped (.claude/rules/asset-pipeline.md). Running it is a decision, not a
step in the pipeline.
"""
from tooling.domains.atlas.planet import import_heightmaps as impl
impl.main(_flags(db=db, body=body, limit=limit, dry_run=dry_run, skip_rename=skip_rename))
@app.command("import-provinces")
@command
def import_provinces(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
force: bool = typer.Option(False, "--force", help="Recompute bodies that already have rows."),
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
verbose: bool = typer.Option(False, "--verbose", help="Per-body detail."),
) -> None:
"""Derive province boundaries from watershed analysis (D-205, D-208).
The other one-time build import — same standing as import-heightmaps.
Expect ~1520 minutes for a full run; `--detach` and tail it.
"""
from tooling.domains.atlas.planet import import_province_boundaries as impl
impl.main(_flags(db=db, body=body, force=force, dry_run=dry_run, verbose=verbose))
@app.command("terrain-reference")
@command
def terrain_reference(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
) -> None:
"""Populate the terrain reference table."""
from tooling.domains.atlas.planet import populate_terrain_reference as impl
impl.main(_flags(db=db))
# --- Sol ------------------------------------------------------------------
@app.command("sol-import")
@command
def sol_import(
body: list[str] = typer.Option(None, "--body", help="Limit to these Sol bodies (repeatable)."),
download_only: bool = typer.Option(False, "--download-only", help="Fetch source data only."),
output_dir: Path = typer.Option(None, "--output-dir", help="Where to write output."),
heightmap_size: str = typer.Option(
None, "--heightmap-size", help="Heightmap grid as WxH, e.g. 1024x512."
),
globe_size: int = typer.Option(None, "--globe-size", help="Globe render edge px."),
render_mode: str = typer.Option(None, "--render-mode", help="Renderer mode."),
) -> None:
"""Import real Sol bodies from published elevation data."""
from tooling.domains.atlas.planet import sol_import as impl
impl.main(_flags(
body=body,
download_only=download_only,
output_dir=output_dir,
heightmap_size=heightmap_size,
globe_size=globe_size,
render_mode=render_mode,
))
@app.command("sol-name-fixes")
@command
def sol_name_fixes(
dry_run: bool = typer.Option(False, "--dry-run", help="Report, write nothing."),
) -> None:
"""Name the auto-detected features on Sol bodies that were left unnamed."""
from tooling.domains.atlas.planet import sol_name_fixes as impl
impl.main(_flags(dry_run=dry_run))
# --- audits ---------------------------------------------------------------
@app.command("audit")
@command
def audit(
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
) -> None:
"""Cohesion audit — does the atlas hang together across bodies."""
from tooling.domains.atlas.planet import atlas_cohesion_audit as impl
impl.main(_flags(system=system, body=body, db=db))
@app.command("quality")
@command
def quality(
db: Path = typer.Option(None, "--db", help="Atlas DB path."),
system: str = typer.Option(None, "--system", help="Limit to one system."),
body: str = typer.Option(None, "--body", help="Limit to one body."),
top_collisions: int = typer.Option(None, "--top-collisions", help="How many to list."),
) -> None:
"""Quality analysis — name collisions and distribution."""
from tooling.domains.atlas.planet import atlas_quality_analysis as impl
impl.main(_flags(db=db, system=system, body=body, top_collisions=top_collisions))
@@ -10,9 +10,9 @@ The frontmatter IS the body definition — the generator reads it directly.
Below the frontmatter is space for authored body content (narrative, notes).
Usage:
python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md
python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md --overrides sol_overrides.json
python3 scaffold_bodies.py wiki/star-systems/GJ-144/index.md --dry-run
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md --overrides sol_overrides.json
reach atlas planet scaffold wiki/star-systems/GJ-144/index.md --dry-run
Only creates files that don't exist yet — never overwrites authored content.
Re-running is safe: existing body index.md files are skipped.
@@ -20,25 +20,28 @@ Re-running is safe: existing body index.md files are skipped.
import argparse
import json
import os
import sys
from pathlib import Path
from tooling.core import config, console
from tooling.core.errors import ReachError
# Venv bootstrap
TOOLING_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
try:
import yaml
except ImportError:
# PyYAML is in pyproject.toml deps
print("error: PyYAML not installed — run `make setup-venv`", file=sys.stderr)
sys.exit(1)
raise ReachError("PyYAML not installed — run `make setup-venv`", fix="make install-reach — reach declares PyYAML in its own environment")
from tooling.domains.atlas.planet.body_definition_parser import parse_system
from body_definition_parser import parse_system
def _body_to_frontmatter(bd: dict) -> str:
@@ -147,14 +150,14 @@ def _body_prose(bd: dict, system_dir: Path) -> str:
return "\n".join(lines)
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Scaffold per-body index.md files from a system index.md")
parser.add_argument("system_index", help="Path to system index.md")
parser.add_argument("--overrides", help="Per-body overrides JSON")
parser.add_argument("--dry-run", action="store_true",
help="Print what would be created without writing")
args = parser.parse_args()
args = parser.parse_args(argv)
system_path = Path(args.system_index)
system_dir = system_path.parent
@@ -166,7 +169,7 @@ def main():
overrides = json.load(f)
body_defs = parse_system(str(system_path), overrides=overrides)
print(f"System: {system_path}{len(body_defs)} renderable bodies")
console.event(f"System: {system_path}{len(body_defs)} renderable bodies")
created = 0
skipped = 0
@@ -176,7 +179,7 @@ def main():
index_path = body_dir / "index.md"
if index_path.exists():
print(f" skip {body_id} — index.md exists")
console.event(f" skip {body_id} — index.md exists")
skipped += 1
continue
@@ -185,18 +188,18 @@ def main():
content = f"---\n{frontmatter}\n---\n\n{prose}"
if args.dry_run:
print(f" would create {index_path}")
print(f" {bd.get('planet_class', '?')} / {bd.get('body_type', '?')}")
console.event(f" would create {index_path}")
console.event(f" {bd.get('planet_class', '?')} / {bd.get('body_type', '?')}")
else:
body_dir.mkdir(parents=True, exist_ok=True)
with open(index_path, "w") as f:
f.write(content)
print(f" created {index_path}")
console.event(f" created {index_path}")
created += 1
action = "would create" if args.dry_run else "created"
print(f"\n {action} {created}, skipped {skipped}")
console.event(f" {action} {created}, skipped {skipped}")
if __name__ == "__main__":
@@ -6,25 +6,41 @@ Supports resume for large files and optional SHA-256 verification.
"""
import hashlib
import sys
import urllib.request
from pathlib import Path
from tooling.core import console
CACHE_DIR = Path(__file__).resolve().parent / ".cache"
# urlretrieve calls the hook once per 8 KB block. The old `\r` rewrite made that
# free on a terminal; as stream events it would be tens of thousands of lines in
# a job log, so progress is reported per 10% step (or per 50 MB when the server
# sends no length) instead.
_STEP_PCT = 10
_STEP_MB = 50
_last_step = -1
def _progress_hook(block_num, block_size, total_size):
"""Print download progress."""
"""Report download progress, one event per step."""
global _last_step
if block_num == 0:
_last_step = -1
downloaded = block_num * block_size
mb = downloaded / (1024 * 1024)
if total_size > 0:
pct = min(100.0, downloaded * 100.0 / total_size)
mb = downloaded / (1024 * 1024)
total_mb = total_size / (1024 * 1024)
sys.stdout.write(f"\r downloading: {mb:.1f}/{total_mb:.1f} MB ({pct:.0f}%)")
fraction = min(1.0, downloaded / total_size)
step = int(fraction * 100) // _STEP_PCT
if step != _last_step:
_last_step = step
total_mb = total_size / (1024 * 1024)
console.event(f"downloading: {mb:.1f}/{total_mb:.1f} MB", progress=fraction)
else:
mb = downloaded / (1024 * 1024)
sys.stdout.write(f"\r downloading: {mb:.1f} MB")
sys.stdout.flush()
step = int(mb) // _STEP_MB
if step != _last_step:
_last_step = step
console.event(f"downloading: {mb:.1f} MB")
def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
@@ -44,14 +60,14 @@ def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
if sha256:
actual = _sha256(local_path)
if actual != sha256:
print(f" WARNING: checksum mismatch for {filename}, re-downloading")
console.event(f"checksum mismatch for {filename}, re-downloading", level="warn")
local_path.unlink()
else:
return local_path
else:
return local_path
print(f" fetching {filename} from {url[:80]}...")
console.event(f" fetching {filename} from {url[:80]}...")
tmp_path = local_path.with_suffix(".tmp")
try:
@@ -62,7 +78,6 @@ def ensure_cached(url: str, filename: str, sha256: str = None) -> Path:
]
urllib.request.install_opener(opener)
urllib.request.urlretrieve(url, str(tmp_path), reporthook=_progress_hook)
print() # newline after progress
except Exception as e:
if tmp_path.exists():
tmp_path.unlink()
@@ -16,13 +16,14 @@ import zipfile
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array,
resample_to_grid, normalize_01, compute_sea_level,
compute_hillshade, assemble_terrain,
)
from tooling.core import console
# ─── Data source URLs ───────────────────────────────────────────────────────
@@ -113,7 +114,7 @@ def _match_river_name(feature_name: str) -> str:
def _load_etopo() -> np.ndarray:
"""Load ETOPO 2022 elevation data, return raw metres array."""
path = ensure_cached(ETOPO_URL, ETOPO_FILE)
print(f" loading ETOPO: {path}")
console.event(f" loading ETOPO: {path}")
try:
arr = load_tiff_as_array(str(path))
except Exception as e:
@@ -122,7 +123,7 @@ def _load_etopo() -> np.ndarray:
f"If PIL can't read this TIFF, install Pillow with TIFF support "
f"or convert to raw binary."
) from e
print(f" ETOPO shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" ETOPO shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
@@ -132,7 +133,7 @@ def _load_worldclim_temperature() -> np.ndarray:
Returns temperature in Kelvin at native resolution.
"""
zip_path = ensure_cached(WCLIM_TEMP_URL, WCLIM_TEMP_FILE)
print(f" loading WorldClim temperature: {zip_path}")
console.event(f" loading WorldClim temperature: {zip_path}")
# The zip contains monthly TIFFs (tavg_01.tif to tavg_12.tif).
# Compute annual mean from all 12 months.
@@ -177,7 +178,7 @@ def _load_worldclim_temperature() -> np.ndarray:
# Replace NaN (ocean/nodata) with a reasonable ocean temperature
temp_K = np.nan_to_num(temp_K, nan=288.0)
print(f" WorldClim temp shape: {temp_K.shape}, "
console.event(f" WorldClim temp shape: {temp_K.shape}, "
f"range: [{np.nanmin(temp_K):.0f}, {np.nanmax(temp_K):.0f}] K")
return temp_K
@@ -188,7 +189,7 @@ def _load_worldclim_precipitation() -> np.ndarray:
Returns precipitation in mm/year at native resolution.
"""
zip_path = ensure_cached(WCLIM_PREC_URL, WCLIM_PREC_FILE)
print(f" loading WorldClim precipitation: {zip_path}")
console.event(f" loading WorldClim precipitation: {zip_path}")
cache_dir = zip_path.parent
annual_sum = None
@@ -219,7 +220,7 @@ def _load_worldclim_precipitation() -> np.ndarray:
if annual_sum is None:
raise RuntimeError("No precipitation TIFFs found in WorldClim archive")
print(f" WorldClim precip shape: {annual_sum.shape}, "
console.event(f" WorldClim precip shape: {annual_sum.shape}, "
f"range: [{annual_sum.min():.0f}, {annual_sum.max():.0f}] mm/yr")
return annual_sum
@@ -230,7 +231,7 @@ def _load_rivers_geojson() -> list:
Returns list of (name, [(row, col), ...]) in grid coordinates.
"""
path = ensure_cached(RIVERS_URL, RIVERS_FILE)
print(f" loading rivers: {path}")
console.event(f" loading rivers: {path}")
with open(path) as f:
geojson = json.load(f)
@@ -280,7 +281,7 @@ def _load_rivers_geojson() -> list:
if name not in by_name or len(path) > len(by_name[name]):
by_name[name] = path
print(f" matched {len(by_name)} rivers: {', '.join(sorted(by_name.keys()))}")
console.event(f" matched {len(by_name)} rivers: {', '.join(sorted(by_name.keys()))}")
return [(name, path) for name, path in by_name.items()]
@@ -292,11 +293,9 @@ def build_terrain(body_def: dict) -> dict:
Returns the same dict format as planet_simulation.simulate().
"""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Earth: loading real-world data...")
console.event(" Earth: loading real-world data...")
# ── 1. Elevation ────────────────────────────────────────────────────
etopo_raw = _load_etopo()
@@ -312,7 +311,7 @@ def build_terrain(body_def: dict) -> dict:
sea_level = compute_sea_level(elevation, EARTH_OCEAN_FRACTION)
surface_water = elevation < sea_level
print(f" elevation: sea_level={sea_level:.4f}, "
console.event(f" elevation: sea_level={sea_level:.4f}, "
f"ocean={surface_water.sum()}/{GRID_H*GRID_W} cells")
# ── 2. Temperature ──────────────────────────────────────────────────
@@ -327,7 +326,7 @@ def build_terrain(body_def: dict) -> dict:
ocean_temp = 301.0 - lat_abs[:, np.newaxis] * 30.0 # ~28°C equator, ~-2°C poles
temperature_K = np.where(surface_water, ocean_temp, temperature_K)
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
precip_raw = _load_worldclim_precipitation()
@@ -340,14 +339,14 @@ def build_terrain(body_def: dict) -> dict:
# Ocean moisture = high (drives adjacent land humidity)
moisture = np.where(surface_water, 0.9, moisture)
print(f" moisture: [{moisture.min():.2f}, {moisture.max():.2f}]")
console.event(f" moisture: [{moisture.min():.2f}, {moisture.max():.2f}]")
# ── 4. Biome classification ─────────────────────────────────────────
# Use the existing Whittaker table with real temperature and moisture
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
n_biomes = len(np.unique(biome))
print(f" biomes: {n_biomes} classes present")
console.event(f" biomes: {n_biomes} classes present")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
@@ -369,7 +368,7 @@ def build_terrain(body_def: dict) -> dict:
n_orig = len(named_rivers)
n_kept = len(clipped)
print(f" rivers: {n_kept}/{n_orig} kept after water clipping")
console.event(f" rivers: {n_kept}/{n_orig} kept after water clipping")
named_rivers = clipped
rivers = [path for _, path in named_rivers]
@@ -9,6 +9,7 @@ The actual overrides are in sol_overrides.json and applied by the body
definition parser. This module exists for future enhancement (ring tuning,
storm placement, etc).
"""
from tooling.core import console
def validate_gas_giant_def(body_def: dict) -> bool:
@@ -19,7 +20,7 @@ def validate_gas_giant_def(body_def: dict) -> bool:
gg = body_def.get("gas_giant", {})
if not gg.get("band_palette"):
print(f" WARNING: {body_def['id']} missing gas_giant.band_palette")
console.event(f"{body_def['id']} missing gas_giant.band_palette", level="warn")
return False
return True
@@ -10,16 +10,16 @@ Each moon gets specific temperature and appearance tuning.
"""
import numpy as np
from pathlib import Path
from scipy.ndimage import gaussian_filter
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# ─── Per-moon configuration ─────────────────────────────────────────────────
@@ -67,12 +67,12 @@ def _load_mosaic_as_elevation(config: dict) -> np.ndarray:
"""Load a global mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(config["mosaic_url"], config["mosaic_file"])
print(f" loading {config['name']} mosaic: {path}")
console.event(f" loading {config['name']} mosaic: {path}")
albedo = load_image_as_elevation(str(path),
invert=config.get("invert_albedo", False))
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
except Exception as e:
print(f" WARNING: {config['name']} mosaic unavailable ({e}), synthetic")
console.event(f"{config['name']} mosaic unavailable ({e}), synthetic", level="warn")
albedo = _synthetic_ice_terrain(config["name"])
# Smooth albedo to create plausible topography
@@ -99,9 +99,7 @@ def _synthetic_ice_terrain(name: str) -> np.ndarray:
def build_terrain(body_def: dict) -> dict:
"""Build ice moon terrain dict from mosaic data."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
body_id = body_def["id"]
config = MOON_CONFIG.get(body_id)
@@ -109,7 +107,7 @@ def build_terrain(body_def: dict) -> dict:
if config is None:
raise ValueError(f"No ice moon config for {body_id}")
print(f" {config['name']}: loading data...")
console.event(f" {config['name']}: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_mosaic_as_elevation(config)
@@ -16,16 +16,16 @@ Properties:
"""
import numpy as np
from pathlib import Path
from scipy.ndimage import gaussian_filter
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# Io global mosaic (Galileo SSI + Voyager) — JPEG from USGS
# If direct download isn't available, fall back to procedural
@@ -40,10 +40,10 @@ def _load_io_mosaic() -> np.ndarray:
"""Load Io global mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(IO_MOSAIC_URL, IO_MOSAIC_FILE)
print(f" loading Io mosaic: {path}")
console.event(f" loading Io mosaic: {path}")
albedo = load_image_as_elevation(str(path), invert=False)
except Exception as e:
print(f" WARNING: Io mosaic unavailable ({e}), generating synthetic")
console.event(f"Io mosaic unavailable ({e}), generating synthetic", level="warn")
return _synthetic_io_terrain()
# Resample to grid
@@ -76,11 +76,9 @@ def _synthetic_io_terrain() -> np.ndarray:
def build_terrain(body_def: dict) -> dict:
"""Build Io terrain dict."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Io: loading data...")
console.event(" Io: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_io_mosaic()
@@ -14,16 +14,16 @@ Luna properties:
"""
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_raw_binary,
resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# LOLA GDR — available as PDS IMG files
# 4ppd (1440 × 720) — compact version
@@ -53,7 +53,7 @@ def _load_lola(use_16ppd: bool = False) -> np.ndarray:
url, filename, w, h = LOLA_4PPD_URL, LOLA_4PPD_FILE, LOLA_4PPD_W, LOLA_4PPD_H
path = ensure_cached(url, filename)
print(f" loading LOLA: {path} ({w}x{h})")
console.event(f" loading LOLA: {path} ({w}x{h})")
# LOLA GDR: little-endian int16 (LSB_INTEGER per PDS label)
# with a scaling factor of 0.5 metres.
@@ -69,23 +69,21 @@ def _load_lola(use_16ppd: bool = False) -> np.ndarray:
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
print(f" LOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" LOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
def build_terrain(body_def: dict) -> dict:
"""Build Luna terrain dict from LOLA data."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Luna: loading LOLA data...")
console.event(" Luna: loading LOLA data...")
# ── 1. Elevation ────────────────────────────────────────────────────
lola_raw = _load_lola(use_16ppd=False)
# LOLA cylindrical: col 0 = 0° longitude — shift to 180°W
from sol_data.shared import greenwich_to_dateline
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
lola_shifted = greenwich_to_dateline(lola_raw)
elevation_m = resample_to_grid(lola_shifted, GRID_H, GRID_W, order=1)
@@ -95,7 +93,7 @@ def build_terrain(body_def: dict) -> dict:
sea_level = 0.0
surface_water = np.zeros((GRID_H, GRID_W), dtype=bool)
print(" elevation normalised")
console.event(" elevation normalised")
# ── 2. Temperature ──────────────────────────────────────────────────
temperature_K = temperature_grid_analytical(
@@ -107,7 +105,7 @@ def build_terrain(body_def: dict) -> dict:
# Clamp minimum
temperature_K = np.maximum(temperature_K, 40.0)
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
@@ -116,7 +114,7 @@ def build_terrain(body_def: dict) -> dict:
# body_type: "moon" + atmosphere: "none" → lunar palette (31/32/33)
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
print(f" biomes: {len(np.unique(biome))} classes")
console.event(f" biomes: {len(np.unique(biome))} classes")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
@@ -17,13 +17,14 @@ Mars properties:
import numpy as np
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_raw_binary, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# MOLA MEGDR — 4 pixels per degree (1440 × 720), big-endian int16
# Each pixel = metres relative to Mars areoid
@@ -62,7 +63,7 @@ def _load_mola(use_16ppd: bool = False) -> np.ndarray:
url, filename, w, h = MOLA_4PPD_URL, MOLA_4PPD_FILE, MOLA_4PPD_W, MOLA_4PPD_H
path = ensure_cached(url, filename)
print(f" loading MOLA: {path} ({w}x{h})")
console.event(f" loading MOLA: {path} ({w}x{h})")
# MOLA MEGDR: big-endian int16, metres, no header
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
@@ -71,19 +72,19 @@ def _load_mola(use_16ppd: bool = False) -> np.ndarray:
arr[arr > 30000] = 0.0
arr[arr < -30000] = 0.0
print(f" MOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" MOLA range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
def build_terrain(body_def: dict) -> dict:
"""Build Mars terrain dict from MOLA data."""
print(" Mars: loading MOLA data...")
console.event(" Mars: loading MOLA data...")
# ── 1. Elevation ────────────────────────────────────────────────────
mola_raw = _load_mola(use_16ppd=False)
# MOLA is col 0 = 0° longitude — shift to col 0 = 180°W
from sol_data.shared import greenwich_to_dateline
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
mola_shifted = greenwich_to_dateline(mola_raw)
# Resample to grid
@@ -92,7 +93,7 @@ def build_terrain(body_def: dict) -> dict:
# Normalise to [0, 1]
elevation = normalize_01(elevation_m, MARS_MIN_ELEV_M, MARS_MAX_ELEV_M)
print(" elevation normalised")
console.event(" elevation normalised")
# ── 2. Temperature ──────────────────────────────────────────────────
# Analytical: equatorial ~210K, polar ~150K, elevation lapse
@@ -109,7 +110,7 @@ def build_terrain(body_def: dict) -> dict:
polar_rows = lat_abs > 0.75
temperature_K[polar_rows, :] = np.minimum(temperature_K[polar_rows, :], 155.0)
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
# Mars has almost no moisture — thin atmosphere
@@ -120,7 +121,7 @@ def build_terrain(body_def: dict) -> dict:
# ── 4. Terraformed water bodies ─────────────────────────────────────
# Lore: 800 years of partial terraforming. Water pools in the deepest
# basins (Hellas, Utopia, Isidis). ~2% of surface is now liquid water.
from sol_data.shared import compute_sea_level as _compute_sl
from tooling.domains.atlas.planet.sol_data.shared import compute_sea_level as _compute_sl
from scipy.ndimage import binary_dilation
TERRAFORM_OCEAN_FRAC = 0.02 # 2% water coverage
@@ -131,7 +132,7 @@ def build_terrain(body_def: dict) -> dict:
surface_water[polar_rows, :] = False
n_water = int(surface_water.sum())
print(f" terraformed water: {n_water} cells "
console.event(f" terraformed water: {n_water} cells "
f"(sea_level={sea_level:.4f})")
# ── 5. Biome classification ─────────────────────────────────────────
@@ -170,7 +171,7 @@ def build_terrain(body_def: dict) -> dict:
n_ferric = int(((biome >= 34) & (biome <= 36)).sum())
n_veg = int(((biome == 8) | (biome == 12)).sum())
n_ocean = int(((biome >= 0) & (biome <= 2)).sum())
print(f" biomes: {len(np.unique(biome))} classes "
console.event(f" biomes: {len(np.unique(biome))} classes "
f"(ferric={n_ferric}, ice={n_ice}, veg={n_veg}, water={n_ocean})")
# ── 5. Hillshade ────────────────────────────────────────────────────
@@ -14,16 +14,16 @@ Mercury properties:
"""
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array, load_raw_binary,
resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# MESSENGER DEM — try PDS binary first (compact), fall back to USGS GeoTIFF
MESSENGER_PDS_URL = "https://pds-geosciences.wustl.edu/messenger/mess-h-mdis_mla-6-dem-elevation-v1/messdmdem_1001/data/global_dem_16ppd.img"
@@ -46,46 +46,42 @@ def _load_messenger() -> np.ndarray:
# Try PDS binary first (compact ~33 MB)
try:
path = ensure_cached(MESSENGER_PDS_URL, MESSENGER_PDS_FILE)
print(f" loading MESSENGER PDS: {path}")
console.event(f" loading MESSENGER PDS: {path}")
arr = load_raw_binary(str(path), MESSENGER_PDS_W, MESSENGER_PDS_H,
dtype=">i2", offset=0)
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
print(f" MESSENGER range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" MESSENGER range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e:
print(f" PDS load failed ({e}), trying USGS GeoTIFF...")
console.event(f" PDS load failed ({e}), trying USGS GeoTIFF...")
# Fallback: USGS GeoTIFF (~506 MB)
try:
path = ensure_cached(MESSENGER_TIFF_URL, MESSENGER_TIFF_FILE)
print(f" loading MESSENGER GeoTIFF: {path}")
console.event(f" loading MESSENGER GeoTIFF: {path}")
arr = load_tiff_as_array(str(path))
arr[arr < -20000] = 0.0
print(f" MESSENGER shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
console.event(f" MESSENGER shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e2:
print(f" GeoTIFF also failed ({e2}), using procedural")
console.event(f" GeoTIFF also failed ({e2}), using procedural")
return None
def build_terrain(body_def: dict) -> dict:
"""Build Mercury terrain dict from MESSENGER data."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Mercury: loading MESSENGER data...")
console.event(" Mercury: loading MESSENGER data...")
# ── 1. Elevation ────────────────────────────────────────────────────
raw = _load_messenger()
if raw is None:
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import simulate
from tooling.domains.atlas.planet.planet_simulation import simulate
return simulate(body_def)
from sol_data.shared import greenwich_to_dateline
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
shifted = greenwich_to_dateline(raw)
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
elevation = normalize_01(elevation_m, MERCURY_MIN_ELEV_M, MERCURY_MAX_ELEV_M)
@@ -18,15 +18,15 @@ Properties:
"""
import numpy as np
from pathlib import Path
from scipy.ndimage import gaussian_filter
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
)
from tooling.core import console
# Cassini ISS global mosaic
TITAN_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/5e5ba96a58d3b38ee6e7b1e94b8c44e6_titan_iss_p19658_mosaic_global_4km.jpg"
@@ -40,11 +40,11 @@ def _load_titan_mosaic() -> np.ndarray:
"""Load Titan mosaic and convert to synthetic elevation."""
try:
path = ensure_cached(TITAN_MOSAIC_URL, TITAN_MOSAIC_FILE)
print(f" loading Titan mosaic: {path}")
console.event(f" loading Titan mosaic: {path}")
albedo = load_image_as_elevation(str(path), invert=False)
albedo = resample_to_grid(albedo, GRID_H, GRID_W, order=1)
except Exception as e:
print(f" WARNING: Titan mosaic unavailable ({e}), synthetic")
console.event(f"Titan mosaic unavailable ({e}), synthetic", level="warn")
albedo = _synthetic_titan_terrain()
# Dark regions = low (lakes/flat), bright = dunes/highlands
@@ -66,11 +66,9 @@ def _synthetic_titan_terrain() -> np.ndarray:
def build_terrain(body_def: dict) -> dict:
"""Build Titan terrain dict."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Titan: loading data...")
console.event(" Titan: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_titan_mosaic()
@@ -78,7 +76,7 @@ def build_terrain(body_def: dict) -> dict:
# Titan has methane lakes — set sea level to create them
# Lakes are concentrated at north polar regions
# Use a low sea level so that only the darkest (lowest) areas become liquid
from sol_data.shared import compute_sea_level
from tooling.domains.atlas.planet.sol_data.shared import compute_sea_level
sea_level = compute_sea_level(elevation, TITAN_METHANE_LAKE_FRACTION)
surface_water = elevation < sea_level
@@ -90,7 +88,7 @@ def build_terrain(body_def: dict) -> dict:
equatorial_mask = (lat_abs < 0.6)[:, np.newaxis] * np.ones(GRID_W, dtype=bool)
surface_water = surface_water & ~equatorial_mask
print(f" methane lakes: {surface_water.sum()} cells")
console.event(f" methane lakes: {surface_water.sum()} cells")
# ── 2. Temperature ──────────────────────────────────────────────────
# Titan has nearly uniform surface temp due to dense atmosphere + distance
@@ -118,7 +116,7 @@ def build_terrain(body_def: dict) -> dict:
# Override: methane lakes should be ocean classes, not ice
# (The biome function sets ocean depth bands for surface_water, which is
# what we want — methane lakes rendered like ocean)
print(f" biomes: {len(np.unique(biome))} classes")
console.event(f" biomes: {len(np.unique(biome))} classes")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
@@ -14,15 +14,15 @@ Venus properties:
"""
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
from tooling.domains.atlas.planet.sol_data.download import ensure_cached
from tooling.domains.atlas.planet.sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array, load_raw_binary, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
temperature_grid_analytical,
)
from tooling.core import console
# Magellan topography — USGS GeoTIFF (reliable, PIL-loadable)
MAGELLAN_TIFF_URL = "https://planetarymaps.usgs.gov/mosaic/Venus_Magellan_Topography_Global_4641m_v02.tif"
@@ -42,56 +42,52 @@ def _load_magellan() -> np.ndarray:
# Try USGS GeoTIFF first (reliable, well-defined format)
try:
path = ensure_cached(MAGELLAN_TIFF_URL, MAGELLAN_TIFF_FILE)
print(f" loading Magellan GeoTIFF: {path}")
console.event(f" loading Magellan GeoTIFF: {path}")
arr = load_tiff_as_array(str(path))
# Handle nodata
arr[arr < -20000] = 0.0
arr[arr > 20000] = 0.0
print(f" Magellan shape: {arr.shape}, "
console.event(f" Magellan shape: {arr.shape}, "
f"range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
except Exception as e:
print(f" GeoTIFF failed ({e}), trying PDS binary...")
console.event(f" GeoTIFF failed ({e}), trying PDS binary...")
# PDS fallback — try common dimension/format combinations
try:
path = ensure_cached(MAGELLAN_PDS_URL, MAGELLAN_PDS_FILE)
print(f" loading Magellan PDS: {path}")
console.event(f" loading Magellan PDS: {path}")
for w, h in [(4096, 2048), (2048, 1024), (8192, 4096)]:
try:
arr = load_raw_binary(str(path), w, h, dtype=">i2", offset=0)
arr[arr > 20000] = 0.0
arr[arr < -20000] = 0.0
print(f" Magellan PDS: {w}x{h}, range: [{arr.min():.0f}, {arr.max():.0f}]")
console.event(f" Magellan PDS: {w}x{h}, range: [{arr.min():.0f}, {arr.max():.0f}]")
return arr
except ValueError:
continue
except Exception as e3:
print(f" PDS also failed ({e3})")
console.event(f" PDS also failed ({e3})")
# All sources failed — fall through to procedural generation
print(" WARNING: all Magellan sources failed, using procedural")
console.event("all Magellan sources failed, using procedural", level="warn")
return None
def build_terrain(body_def: dict) -> dict:
"""Build Venus terrain dict from Magellan data."""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
from tooling.domains.atlas.planet.planet_simulation import compute_biome
print(" Venus: loading Magellan data...")
console.event(" Venus: loading Magellan data...")
# ── 1. Elevation ────────────────────────────────────────────────────
raw = _load_magellan()
if raw is None:
# Fall back to procedural simulation
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import simulate
from tooling.domains.atlas.planet.planet_simulation import simulate
return simulate(body_def)
from sol_data.shared import greenwich_to_dateline
from tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
shifted = greenwich_to_dateline(raw)
elevation_m = resample_to_grid(shifted, GRID_H, GRID_W, order=1)
elevation = normalize_01(elevation_m, VENUS_MIN_ELEV_M, VENUS_MAX_ELEV_M)
@@ -7,40 +7,44 @@ markers.json, terrain.npz) by constructing terrain dicts from real
planetary science data instead of procedural simulation.
Usage:
python3 sol_import.py # All Sol bodies
python3 sol_import.py --body GJ0d # Earth only
python3 sol_import.py --body GJ0d --body GJ0e # Earth + Mars
python3 sol_import.py --download-only # Fetch data, skip rendering
python3 sol_import.py --heightmap-size 2048x1024 --globe-size 1024
reach atlas planet sol-import # All Sol bodies
reach atlas planet sol-import --body GJ0d # Earth only
reach atlas planet sol-import --body GJ0d --body GJ0e # Earth + Mars
reach atlas planet sol-import --download-only # Fetch data, skip rendering
reach atlas planet sol-import --heightmap-size 2048x1024 --globe-size 1024
Data is cached in tooling/planet-gen/sol_data/.cache/ after first download.
Data is cached in tooling/domains/atlas/planet/sol_data/.cache/ after first download.
"""
import argparse
import json
import os
import sys
import time
# Venv bootstrap — re-exec into .venv/bin/python if not already there.
from pathlib import Path
TOOLING_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = (TOOLING_DIR / ".." / "..").resolve()
_venv_python = WORKTREE_ROOT / ".venv" / "bin" / "python"
if _venv_python.exists() and Path(sys.executable).resolve() != _venv_python.resolve():
os.execv(str(_venv_python), [str(_venv_python)] + sys.argv)
from tooling.core import config, console
from tooling.core.errors import ReachError
PLANET_DIR = Path(__file__).resolve().parent
WORKTREE_ROOT = config.repo_root()
# The venv re-exec that used to sit here is gone (T-1288). It relaunched the
# script under .venv/bin/python so numpy would resolve when run by path.
# reach declares numpy and Pillow itself, so its own environment already has
# them — and an os.execv into a different interpreter, carrying reach's
# argv, would have relaunched something that is not this command at all.
import numpy as np
from planet_simulation import simulate
from render_heightmap import render_heightmap
from generate import _build_markers
from tooling.domains.atlas.planet.planet_simulation import simulate
from tooling.domains.atlas.planet.render_heightmap import render_heightmap
from tooling.domains.atlas.planet.generate import _build_markers
# Per-body importers (lazy-loaded)
SOL_INDEX = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "index.md"
SOL_OVERRIDES = TOOLING_DIR / "sol_overrides.json"
SOL_OVERRIDES = PLANET_DIR / "sol_overrides.json"
SOL_BODIES_DIR = WORKTREE_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
SOL_MARKERS_DIR = TOOLING_DIR / "sol_markers"
SOL_MARKERS_DIR = PLANET_DIR / "sol_markers"
# Bodies that use real-world data (keyed by body_id → importer module)
REAL_DATA_BODIES = {
@@ -67,7 +71,7 @@ SKIP_TYPES = {"asteroid_belt", "oort_cloud"}
def _load_importer(module_name: str):
"""Lazy-import a sol_data.* module."""
import importlib
return importlib.import_module(f"sol_data.{module_name}")
return importlib.import_module(f"tooling.domains.atlas.planet.sol_data.{module_name}")
def _apply_named_features(markers: dict, body_id: str) -> dict:
@@ -173,13 +177,13 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
# Skip non-renderable types
if body_type in SKIP_TYPES:
print(f"\n {body_id} ({name}) — skipped ({body_type})")
console.event(f" {body_id} ({name}) — skipped ({body_type})")
return
body_dir = output_dir / body_id
body_dir.mkdir(parents=True, exist_ok=True)
print(f"\n {body_id} ({name}) — {planet_class}")
console.event(f" {body_id} ({name}) — {planet_class}")
t0 = time.time()
@@ -190,33 +194,33 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
if is_gas:
# Gas giants: no terrain, renderer handles bands procedurally
terrain = {}
print(" terrain: gas giant (procedural bands)")
console.event(" terrain: gas giant (procedural bands)")
elif body_id in REAL_DATA_BODIES:
# Real-world data import
module_name = REAL_DATA_BODIES[body_id]
print(f" importing real data via sol_data.{module_name}...")
console.event(f" importing real data via sol_data.{module_name}...")
importer = _load_importer(module_name)
terrain = importer.build_terrain(body_def)
if download_only:
print(" download complete, skipping render")
console.event(" download complete, skipping render")
return
elif body_id in PROCEDURAL_BODIES:
# Fall through to standard procedural simulation
print(" procedural simulation (irregular body)...")
console.event(" procedural simulation (irregular body)...")
terrain = simulate(body_def)
else:
print(f" WARNING: no importer for {body_id}, using procedural")
console.event(f"no importer for {body_id}, using procedural", level="warn")
terrain = simulate(body_def)
t_terrain = time.time()
if terrain:
print(f" terrain: {t_terrain - t0:.1f}s "
console.event(f" terrain: {t_terrain - t0:.1f}s "
f"sea={terrain['sea_level']:.3f} "
f"land={int((~terrain['surface_water']).sum())} "
f"rivers={len(terrain['rivers'])}")
else:
print(f" terrain: gas giant ({t_terrain - t0:.1f}s)")
console.event(f" terrain: gas giant ({t_terrain - t0:.1f}s)")
# ── 2. Render heightmap ─────────────────────────────────────────────
t_hmap = t_terrain
@@ -226,17 +230,17 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
render_mode=render_mode, chrome=False)
hmap_img.save(str(body_dir / "heightmap.png"))
t_hmap = time.time()
print(f" heightmap: {t_hmap - t_terrain:.1f}s {hmap_w}x{hmap_h}")
console.event(f" heightmap: {t_hmap - t_terrain:.1f}s {hmap_w}x{hmap_h}")
# ── 3. Render globe ─────────────────────────────────────────────────
try:
from planet_renderer import render_globe
from tooling.domains.atlas.planet.planet_renderer import render_globe
globe_img = render_globe(body_def, terrain, size=globe_size)
globe_img.save(str(body_dir / "globe.png"))
t_globe = time.time()
print(f" globe: {t_globe - t_hmap:.1f}s {globe_size}x{globe_size}")
console.event(f" globe: {t_globe - t_hmap:.1f}s {globe_size}x{globe_size}")
except Exception as e:
print(f" globe: FAILED — {e}")
console.event(f" globe: FAILED — {e}")
t_globe = time.time()
# ── 4. Write data files ─────────────────────────────────────────────
@@ -260,7 +264,7 @@ def _generate_body(body_def: dict, hmap_w: int, hmap_h: int,
_write_index_md(body_def, body_dir)
elapsed = time.time() - t0
print(f" total: {elapsed:.1f}s -> {body_dir}/")
console.event(f" total: {elapsed:.1f}s -> {body_dir}/")
def _write_index_md(body_def: dict, body_dir: Path):
@@ -298,7 +302,7 @@ def _write_index_md(body_def: dict, body_dir: Path):
f.write(md)
def main():
def main(argv: list[str] | None = None):
parser = argparse.ArgumentParser(
description="Sol system (GJ-0) real-world terrain importer")
@@ -315,21 +319,19 @@ def main():
parser.add_argument("--render-mode", choices=["cartographic", "photographic"],
default="cartographic")
args = parser.parse_args()
args = parser.parse_args(argv)
# Parse heightmap size
try:
hw, hh = args.heightmap_size.lower().split("x")
hmap_w, hmap_h = int(hw), int(hh)
except ValueError:
print(f"error: invalid heightmap size '{args.heightmap_size}'",
file=sys.stderr)
sys.exit(1)
raise ReachError(f"invalid heightmap size '{args.heightmap_size}'", fix="pass --heightmap-size as WxH, e.g. 1024x512")
output_dir = Path(args.output_dir) if args.output_dir else SOL_BODIES_DIR
# Parse body definitions from GJ-0 index.md
from body_definition_parser import parse_system
from tooling.domains.atlas.planet.body_definition_parser import parse_system
overrides = {}
if SOL_OVERRIDES.exists():
@@ -337,15 +339,14 @@ def main():
overrides = json.load(f)
body_defs = parse_system(str(SOL_INDEX), overrides=overrides)
print(f"Sol system: {len(body_defs)} bodies parsed")
console.event(f"Sol system: {len(body_defs)} bodies parsed")
# Filter to requested bodies
if args.body:
requested = set(args.body)
body_defs = [bd for bd in body_defs if bd["id"] in requested]
if not body_defs:
print(f"error: no matching bodies for {args.body}", file=sys.stderr)
sys.exit(1)
raise ReachError(f"no matching bodies for {args.body}", fix="pass a Sol body id from wiki/star-systems/GJ-0/bodies/ (e.g. GJ0c), or omit --body")
# Generate
t_total = time.time()
@@ -356,14 +357,14 @@ def main():
args.render_mode, output_dir,
download_only=args.download_only)
except Exception as e:
print(f"\n FAILED: {bd['id']}{e}")
console.event(f" FAILED: {bd['id']}{e}")
failed.append(bd["id"])
elapsed = time.time() - t_total
n_ok = len(body_defs) - len(failed)
print(f"\n Done: {n_ok}/{len(body_defs)} bodies in {elapsed:.1f}s")
console.event(f" Done: {n_ok}/{len(body_defs)} bodies in {elapsed:.1f}s")
if failed:
print(f" Failed: {', '.join(failed)}")
console.event(f" Failed: {', '.join(failed)}")
if __name__ == "__main__":
@@ -6,14 +6,16 @@ Targets features with null names: Earth oceans/rivers, Luna/Mars/Europa mountain
All names are real-world geographic names for Sol bodies.
Usage:
python3 sol_name_fixes.py # apply all fixes
python3 sol_name_fixes.py --dry-run # print planned changes without writing
reach atlas planet sol-name-fixes # apply all fixes
reach atlas planet sol-name-fixes --dry-run # print planned changes without writing
"""
import argparse
import json
from pathlib import Path
REPO_ROOT = (Path(__file__).resolve().parent / ".." / "..").resolve()
from tooling.core import config, console
REPO_ROOT = config.repo_root()
WIKI = REPO_ROOT / "wiki" / "star-systems" / "GJ-0" / "bodies"
# Keys are feature IDs; values are the names to assign.
@@ -102,7 +104,7 @@ def apply_sol_fixes(dry_run: bool = False) -> None:
for body_id, body_fixes in FIXES.items():
path = WIKI / body_id / "markers.json"
if not path.exists():
print(f" SKIP {body_id}: markers.json not found")
console.event(f" SKIP {body_id}: markers.json not found")
continue
with open(path) as f:
@@ -117,24 +119,23 @@ def apply_sol_fixes(dry_run: bool = False) -> None:
old = feature.get("name")
new = id_map[fid]
if old != new:
print(f" [{body_id}/{section}] {fid}: {old!r}{new!r}")
console.event(f" [{body_id}/{section}] {fid}: {old!r}{new!r}")
if not dry_run:
feature["name"] = new
changed = True
if changed:
if dry_run:
print(f" (dry-run) Would write: {path}")
console.event(f" (dry-run) Would write: {path}")
else:
with open(path, "w") as f:
json.dump(markers, f, indent=2)
print(f" Written: {path}")
console.event(f" Written: {path}")
else:
print(f" No changes for {body_id}")
print()
console.event(f" No changes for {body_id}")
if __name__ == "__main__":
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Name previously-unnamed Sol body auto-detected features."
)
@@ -143,5 +144,9 @@ if __name__ == "__main__":
action="store_true",
help="print planned changes without writing any files",
)
args = parser.parse_args()
args = parser.parse_args(argv)
apply_sol_fixes(dry_run=args.dry_run)
if __name__ == "__main__":
main()
+12
View File
@@ -154,3 +154,15 @@ def flatness_report(
fix="re-run the capture, or check .cache/screenshots/ for the ladder",
exit_code=code,
)
# --- the rungs below the body surface -------------------------------------
#
# `planet` is a nested GROUP, not a domain of its own: the ladder is one
# subject (D-243), and a body is a rung of it rather than a peer. It is added
# at import time but its own module tree loads only when a `planet` verb runs
# — same lazy contract as the domains themselves.
from tooling.domains.atlas.planet.router import app as _planet_app # noqa: E402
app.add_typer(_planet_app, name="planet")
+2 -2
View File
@@ -15,8 +15,8 @@ REPO = "jpmschweitzer/settled-reach"
# set is read from the registry rather than restated, so this list cannot drift
# from tooling/generator_sources.py (T-1067).
EXTRA_WATCHED = (
"tooling/planet-gen/import_heightmaps.py",
"tooling/planet-gen/import_province_boundaries.py",
"tooling/domains/atlas/planet/import_heightmaps.py",
"tooling/domains/atlas/planet/import_province_boundaries.py",
"server/data/systems-schema.sql",
"wiki/star-systems/",
"wiki/economics/",
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# Batch planet generation — process all systems unattended.
# Usage: tooling/planet-gen/batch [--scaffold-only] [--generate-only] [--system GJ-144]
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/batch.py" "$@"
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# Planet generator CLI wrapper.
# Usage: tooling/planet-gen/generate body_def.json --output-dir ./output
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/generate.py" "$@"
-477
View File
@@ -1,477 +0,0 @@
# Hand-Refine Log — Ticket #849
# Core-world atlas cohesion pass (Sprint 36)
Analyst: paula
Date: 2026-04-19
---
## Analysis infrastructure
Created `tooling/planet-gen/atlas_cohesion_audit.py` — reusable SQL audit script for the
atlas_* tables. Checks: empty/single-char names, generic/lazy outputs (% Fork, % Run, etc.),
cardinal direction density, earth-echo concentration, same-body cross-feature stem duplicates,
cross-body stem duplicates within a system. Accepts `--system`, `--body`, `--db` flags.
Created `tooling/planet-gen/apply_name_fixes.py` — applies curated name replacement tables to
markers.json files (name fields only; geometry preserved). `--dry-run` supported. After running,
caller must sync DB via `generate_atlas.py --body <id>`.
---
## Bodies touched
### GJ 144 — Ran system
#### GJ144d — Kallast (2B pop, urban_concentrated, ocean world)
**Priority reason:** Highest-population body in the Ran system; repeated player destination.
Prior naming pass had done good Nordic groundwork (Rán's Landing, Seterfjellet, Rán's Run/Ranfall
Beck cross-reference arc) but left residual generic oceans and one cross-system stem collision.
**Changes:**
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| river | Aldren Pass | Randalfoss | "Aldren" stem collides with Lendel (GJ380c) capital + river "The Aldren" — cross-system noise |
| ocean | Boulder Bluff | Keldmere | Generic; "Boulder Bluff" is a geographic descriptor for a landform, not a sea |
| ocean | High Mesa | Seterfjord | "High Mesa" is nonsensical for an ocean; renamed to cross-ref mountain "Seterfjellet" |
| poi | Summit Point | Kallast Gate Terminal | Generic; terminal should reference the capital city name |
**Cross-reference arcs post-fix:**
- Rán-: Rán's Landing (city), Rán's Run (river), Randalfoss (river), Ranfall Beck (river), Rán Sea (ocean)
- Seter-: Seterfjellet (mountain), Seterfjord (ocean)
- Keld-: Keldmere (ocean), Timber Keld (river)
**DB sync:** `generate_atlas.py --body GJ144d`
---
#### GJ144e — Vethis (1.2B pop, agricultural, dispersed_rural)
**Priority reason:** Second-most-populated Ran body; the rural agricultural contrast to Kallast.
Prior naming pass was partial — extensive cardinals (West Bend), earth-echoes (Blue Ridge),
overused stems (Ash-: 3 features; Iron-: 2 features), and many pure generics (Golden Valley,
Broad Fork, Stone Point).
**Changes:**
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| river | West Bend | Greywash Fork | Cardinal → cross-refs Greywash river (shared Grey- stem arc) |
| river | Blue Ridge | Ashvale Beck | Earth-echo → cross-refs Ashvale city (shared Ash- stem arc) |
| river | Stone Point | Thornrun | Generic → cross-refs Thorncrests mountain (shared Thorn- arc) |
| river | Ironside Run | Kelside Run | Iron- overuse → cross-refs Kelbridge city (shared Kel- arc) |
| ocean | Ashbluff Sea | Veth Mere | Ash- overuse (3 Ash- names) → cross-refs Veth Delta river (shared Veth- arc) |
| mountain | Golden Valley | Greymoor Range | Generic → extends Grey- arc |
| mountain | Broad Fork | Keld Spur | Generic → Nordic compound (keld = spring/source) |
| mountain | Ashstone Ridge | Greystone Ridge | Ash- overuse → extends Grey- arc |
| poi | Ridge Crossing | Vethis Gate Terminal | Generic → terminal named for the planet |
**Cross-reference arcs post-fix:**
- Grey-: Greywash (river), Greywash Fork (river), Greymoor Range (mountain), Greystone Ridge (mountain)
- Thorn-: Thorncrests (mountain), Thornrun (river)
- Kel-: Kelbridge (city), Kelside Run (river), Keld Spur (mountain)
- Veth-: Vethis (planet), Veth Delta (river), Veth Mere (ocean)
- Ash-: Ashvale (city), Ashvale Beck (river) ← 2 names, was 3 before fix
**DB sync:** `generate_atlas.py --body GJ144e`
---
### GJ 71 — Tau Ceti system
#### GJ71c — Threshold (600M pop, agricultural, urban_concentrated)
**Priority reason:** Tau Ceti's primary inhabited body; high player footfall.
All six rivers were named after the original Commission survey team — a strong narrative hook
("Latin personal names = founding scientists"). "Aethelred" broke the pattern (Anglo-Saxon,
wrong naming tradition).
**Changes:**
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| river | Aethelred | Gaius | Anglo-Saxon name breaks all-Latin river pattern (Octavius, Septimus, Quintus, Valeria, Marcus, Gaius) |
**DB sync:** `generate_atlas.py --body GJ71c`
---
#### GJ71d — Arden (500M pop, agricultural, dispersed_rural)
**Priority reason:** Tau Ceti's sister body; shares corridor with GJ71c, so cross-body stem
duplicates matter most here. Two collisions found: exact POI name match + ocean/city stem match.
**Changes:**
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| city | Concordia Hall | The Praxis | "Concordia" = GJ71c ocean name; cross-body stem collision within same system/corridor |
| river | Basilica Nova | Via Principia | Exact name match with GJ71c POI "Basilica Nova"; direct duplicate across bodies |
**DB sync:** `generate_atlas.py --body GJ71d`
---
#### GJ71d-1 — Verantis (20M pop, transit moon)
**No changes.** Discovered that Verantis was already updated in a prior sprint pass —
mountains renamed to unique Latin institutional names (The Lateranum, The Curia Magna, The Exedra,
The Aedes Sacra, The Macellum, The Oracle, The Comitium, Moeribee), capital updated to "Praetorium".
DB was stale relative to markers.json; synced only.
**DB sync:** `generate_atlas.py --body GJ71d-1`
---
### GJ 244A — Sirius system
#### GJ244Ad — Edict (400M pop, hand-authored template)
**Priority reason:** Only inhabited body in the Sirius system. Hand-authored template is solid
(Mandate, Founder's Range, Accord Peaks, Veto Spur, Concord Assembly Archive) but #833-generated
features used wrong vocabulary for a political-legal world. Note: "Westwall" flagged by Gestalt
does NOT appear in markers.json or DB — stale data issue; Cairnside fix by Gestalt stands, Edict
required no Westwall action.
**Changes:**
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| mountain | Keel Ridge | Charter Spur | "Keel" is nautical — wrong vocabulary; charter = founding document |
| mountain | Sanction Ridge | The Statute | More distinctive; drops lazy "Ridge" suffix |
| ocean | Upper Shelf | Veil Shelf | Cardinal → cross-refs Veil Institute Survey Station POI |
| ocean | Southern Melt | Concord Mere | Cardinal → cross-refs Concord Assembly Archive POI |
| ocean | Eastern Tarn | Charter Tarn | Cardinal → cross-refs mountain "Charter Spur" |
**Cross-reference arcs post-fix:**
- Charter-: Charter Spur (mountain), Charter Tarn (ocean)
- Concord-: Concord Mere (ocean), Concord Assembly Archive (POI)
- Veil-: Veil Shelf (ocean), Veil Institute Survey Station (POI)
**DB sync:** `generate_atlas.py --body GJ244Ad`
---
### GJ 380 — Groombridge system
#### GJ380c — Lendel (900M pop, hand-authored template)
**Priority reason:** Only inhabited body in Groombridge. Template is excellent — Aldren arc
(capital + river + exchange) is intentional and preserved. Two #833-generated features used lazy
suffixes incompatible with the British-Isles naming style.
**Changes:**
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| river | Pale Run | Durneth Beck | Lazy → cross-refs Durneth Range; "beck" = British stream |
| mountain | Tember Ridge | Tember Spine | Lazy "Ridge" → "Spine"; keeps the Tember stem |
**Note:** "Aldren" stem in same-body audit (city + river + POI) is INTENTIONAL arc — preserved.
"Meridian Risk HQ" POI is a corporation name (proper noun) — not geography, no change.
**DB sync:** `generate_atlas.py --body GJ380c`
**Addendum (PR #133 review):** Initial pass left GJ380c 100% Anglo-British register with zero
cross-corridor influence from the Kumasi/south_reach trade lanes. Added two secondary features
in Akan/Asante register, keeping primary features (Aldren capital, Durneth Range, Rethain Sea)
in their established Anglo register:
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| river | The Kesset | Nkwanta Beck | Nkwanta = Akan "junction/crossroads" — trade-route name; Beck suffix localizes it |
| mountain | Holt Spur | Bosomtwe Spur | Bosomtwe = sacred Asante lake in Ghana; Spur suffix preserved from Groombridge convention |
| lake | Selet Basin | Subin Basin | Subin = river running through Kumasi itself — distributes Akan signal across a third feature type |
**Corridors are tendencies, not borders** — capital and primary geography stay Anglo-British;
peripheral features reflect Kumasi trade influence. 3/29 features now carry Akan register (river, mountain, lake). DB re-synced.
---
### GJ 699 — Barnard's Star system
#### GJ699b — Verada (1.9B pop, hop-0, dense urban world)
**Priority reason:** Highest-traffic hop-0 inhabited body not yet addressed. Dense civic naming
(Capitol Heights, Liberty Plaza, Metropolitan Square, Meridian City) was using district/plaza
vocabulary for geographic features. Rivers especially broken: "Grandview Square" and "Meridian
City" are civic addresses, not river names. Cross-body dups on sibling bodies.
**Changes:**
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| river | Grandview Square | Verada Reach | Civic square → planet name + geographic "Reach" |
| river | Meridian City | The Meridian | Civic city name → proper river name (like "The Aldren") |
| river | Summit View | Capitol Beck | Lazy → cross-refs Capitol Heights capital |
| ocean | Prospect Terrace | Prospect Sea | "Terrace" is architecture; fix suffix |
| ocean | Metropolitan Square | Haven Sea | Cross-refs Port Haven city; drops civic |
| ocean | Central District | Meridian Sound | Exact dup of GJ699b-1 mountain; cross-refs The Meridian |
| ocean | Liberty Plaza | Capitol Mere | Civic plaza → cross-refs Capitol Heights capital |
| ocean | Sterling Heights | Sterling Pool | "Heights" wrong for water; dup on GJ699b-1 mountain |
**Cross-reference arcs post-fix:**
- Capitol-: Capitol Heights (city), Capitol Beck (river), Capitol Mere (ocean)
- Meridian-: The Meridian (river), Meridian Sound (ocean)
- Haven/Port-: Port Haven (city), Haven Sea (ocean)
**DB sync:** `generate_atlas.py --body GJ699b`
---
#### GJ699b-1 — Verada's moon (uninhabited transit body)
**Priority reason:** All 8 mountains named after urban street addresses (Grandview Avenue,
Harmony Boulevard, Beacon Street, Metropolitan Way, etc.) — completely inappropriate for
mountain ranges. Also duplicated several Verada city/ocean names exactly ("Central District",
"Capitol Heights"). Fixed with institutional cross-references: the moon's mountains carry the
names of Verada's governing institutions, as if settlers named what they saw below from orbit.
**Changes:** All 8 mountains renamed. See apply_name_fixes.py FIXES table for full mapping.
**Narrative hook:** "On Verada's moon, the mountains carry the names of the institutions
that govern the world below — a form of reverence. The moon has no government of its own."
**DB sync:** sync_markers_to_db() called directly (body is inhabited=0, generate_atlas.py skips it) ✓
---
### Sol (GJ 0) — COMPLETE
**Approach:** sol_import.py generated body directories and markers.json from real-world geographic
data. Post-generation, `sol_name_fixes.py` named all auto-detected null-name features.
Team-lead guidance: "real-world Earth/Luna/Mars geography is by definition coherent — note it
in log and move on if already above quality bar."
#### GJ0d — Earth (8.5B pop, core world)
sol_import.py originally placed 50 cities from earth_features.json. Team-lead directed trim
to 8-12 cultural touchstones (max 8 cities on any other body; 50 = 13% of all atlas cities
on one body). Criterion: would a player setting a bookmark to "Earth" recognize this as a
touchstone? One per major historical/cultural cluster.
**Cities kept (11):**
| City | Cluster |
|---|---|
| London | Western Europe — historical capital |
| Moscow | Eastern Europe / Russia |
| Istanbul | Bridge city — Europe-Asia hinge |
| New York | North America |
| São Paulo | South America |
| Cairo | Africa + ancient world |
| Delhi | South Asia |
| Tokyo | Japan / East Asia |
| Beijing | China / East Asia |
| Singapore | Southeast Asia — maritime hub |
| Sydney | Oceania |
**Cities cut (39):** Paris, Berlin, Mexico City, Los Angeles, Toronto, Chicago, Lima, Bogotá,
Rio de Janeiro, Buenos Aires, Lagos, Kinshasa, Johannesburg, Nairobi, Tehran, Baghdad, Riyadh,
Ankara, Karachi, Shanghai, Mumbai, Jakarta, Dhaka, Manila, Bangkok, Seoul, Osaka, Chongqing,
Kolkata, Lahore, Shenzhen, Bangalore, Ho Chi Minh City, Luanda, Addis Ababa, Santiago, Taipei,
Hong Kong, Casablanca.
**PR #133 review rebalance (user-approved Option 1 + London swap):**
- `Sydney → Lagos` — improves African representation (Cairo + Lagos = 2 African cities; Sydney was the weakest cultural-touchstone anchor).
- `London → Brussels` — Brussels chosen for future-strong-EU-capital setting fit over London's legacy cultural weight.
**Final 11:** Beijing, Brussels, Cairo, Delhi, Istanbul, Lagos, Moscow, New York, São Paulo, Singapore, Tokyo.
**Generator note:** sol_import.py has no `--top-n` city filter — it uses the full earth_features.json
list. If Sol is regenerated, earth_features.json should be trimmed to the 11 kept cities, or a
filter added in sol_import.py. Filed as finding in #853.
auto-detected 11 rivers and 1 ocean. Three rivers were auto-detected ocean-channel artifacts
in the western Pacific island region; named with geographically proximate rivers. The large
ocean (area_fraction=0.7049) represents Earth's interconnected world ocean.
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| ocean | null | The World Ocean | 70% surface = all Earth's oceans unified; scientifically accurate name |
| river | null (river_1, ~50°N/17°E) | Dnieper | Eastern Europe, flows to Black Sea |
| river | null (river_4, ~27°N/136°E) | Tone River | Japan, Kanto plain |
| river | null (river_10, ~23°N/135°E) | Cagayan | Northern Philippines, largest Philippine river |
**DB sync:** `generate_atlas.py --body GJ0d` ✓ (11 cities, trimmed from 50)
---
#### GJ0d-1 — Luna (350M pop, transit body)
3 cities (Artemis capital, Tranquility Station, Selene) + 10 POIs (maria + craters). sol_import.py
auto-detected 24 mountain ranges — all unnamed. Named all 24 after real IAU-catalogued lunar
mountain systems, massifs, and scarps, sorted by area_cells (largest = most prominent).
Key ranges: Montes Apenninus (11,221 cells — largest), Montes Caucasus (3,670), Montes Jura,
Montes Alpes, Montes Rook, Montes Cordillera, Montes Carpatus, Montes Taurus, Montes Pyrenaeus,
and 15 additional real lunar named features (Pico Mons, Haemus Montes, Gruithuisen Domes, etc.).
**Narrative hook:** "Luna's mountains carry the names that Earth gave them a thousand years
before the first settlement — a catalog written from below, now walked from above."
**DB sync:** `generate_atlas.py --body GJ0d-1`
---
#### GJ0e — Mars (1.2B pop, core world)
4 cities + 5 named mountains (Olympus Mons, Tharsis Bulge, Elysium Mons, Ascraeus Mons,
Arsia Mons) from mars_features.json. sol_import.py auto-detected 4 additional mountain ranges
in the anti-Tharsis hemisphere (ancient cratered highland region), named after real Martian
volcanic features.
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| mountain | null (range_2) | Tyrrhena Mons | Ancient volcanic highland, eastern hemisphere |
| mountain | null (range_3) | Hadriaca Mons | Old shield volcano, Hellas region |
| mountain | null (range_4) | Amphitrites Montes | Near south polar region |
| mountain | null (range_5) | Hellespontus Montes | Cross-refs Hellas Station city |
**Cross-reference arcs:** Hellas- (Hellas Basin ocean + Hellas Station city), Chryse- (Chryse Landing + Chryse Planitia POI)
**DB sync:** `generate_atlas.py --body GJ0e`
---
#### GJ0f-2 — Europa (30M pop, outer body)
2 cities + 4 POIs. sol_import.py auto-detected 1 mountain range covering essentially the
entire body surface (area_cells=123,557 — Europa's ridged ice terrain).
| Feature type | Old name | New name | Reason |
|---|---|---|---|
| mountain | null (range_1) | Conamara Ridges | Cross-refs Conamara Station + Conamara Chaos POI |
**Cross-reference arcs:** Conamara- (Conamara Station city + Conamara Ridges mountain + Conamara Chaos POI),
Pwyll- (Pwyll Base city + Pwyll Crater POI)
**DB sync:** `generate_atlas.py --body GJ0f-2`
---
**Sol final audit result:** Zero empty names, zero lazy outputs, zero cardinals.
Earth-echo names on Earth are intentional (real-world cities). Quality cross-reference arcs
confirmed on Mars (Hellas-, Chryse-) and Europa (Conamara-, Pwyll-).
---
### Proxima (GJ 551) — DEFERRED
Cross-body stem duplicates found (Basilica, Pantheon, Senate across Ancora sibling bodies).
Team-lead confirmed: defer this sprint. Finding documented for future pass.
---
## Audit metrics — final confirmed delta
Final audit run: 2026-04-19. All 6 systems re-run after all edits. Results below are
from `atlas_cohesion_audit.py` against `server/data/systems.db`.
### Inhabited body targets (in scope for this ticket)
| System | Body | Empty names: before→after | Lazy outputs: before→after | Cardinals: before→after |
|--------|------|--------------------------|---------------------------|------------------------|
| GJ 144 | GJ144d Kallast (2B) | 0→0 | 3→0 | 0→0 |
| GJ 144 | GJ144e Vethis (1.2B) | 0→0 | 7→0 | 1→0 |
| GJ 71 | GJ71c Threshold (600M) | 0→0 | 1→0 | 0→0 |
| GJ 71 | GJ71d Arden (500M) | 0→0 | 1→0 | 0→0 |
| GJ 71 | GJ71d-1 Verantis (20M) | 0→0 | 0→0 | 0→0 |
| GJ 244A | GJ244Ad Edict (400M) | 0→0 | 2→0 | 3→0 |
| GJ 380 | GJ380c Lendel (900M) | 0→0 | 2→0 | 0→0 |
| GJ 699 | GJ699b Verada (1.9B) | 0→0 | 8→0 | 0→0 |
| GJ 699 | GJ699b-1 (uninhabited moon) | 8→0 | 8→0 | 0→0 |
| GJ 0 | GJ0d Earth (8.5B) | 4→0 | 0→0 | 0→0 |
| GJ 0 | GJ0d-1 Luna (350M) | 24→0 | 0→0 | 0→0 |
| GJ 0 | GJ0e Mars (1.2B) | 4→0 | 0→0 | 0→0 |
| GJ 0 | GJ0f-2 Europa (30M) | 1→0 | 0→0 | 0→0 |
**All inhabited targets: zero empty names, zero lazy outputs, zero cardinals after fixes.**
### Remaining audit flags — out of scope or false positives
After fixes, the audit still reports flags on:
**Out of scope — uninhabited/low-pop bodies (not "high-visibility"):**
- GJ144b, GJ144c, GJ144d-1, GJ144e-1, GJ144f, GJ144g-1, GJ144g-2: "Canyon View", "Dry Gulch",
"Stone Creek" etc. These are #833 batch artifacts on non-target bodies. Captured in #853.
- GJ71e: "Meridian Point" (uninhabited body, not in scope)
- GJ380b, GJ380d, GJ380e: various lazy patterns (uninhabited, not in scope)
**False positives on quality cross-reference names (distinctive stem + common suffix):**
- `'Rán's Run'` (GJ144d) — Rán- arc; % Run pattern-matched but stem is unique proper name
- `'Greywash Fork'` (GJ144e) — Grey- arc; % Fork but Greywash is not a generic stem
- `'Kelside Run'` (GJ144e) — Kel- arc; % Run but Kelside is distinctive
- `'Greystone Ridge'` (GJ144e) — Grey- arc; % Ridge but Greystone is distinctive
These four are intentional renames (listed in the FIXES table above) that happen to end with
a suffix in LAZY_PATTERNS. The script does not evaluate stem quality, only suffix pattern.
A future pass on the audit script could add a stem-distinctiveness filter.
### Earth city count correction
Per team-lead direction: Earth trimmed from **50 → 11 cities** (cultural/historical touchstones,
one per major cluster). 39 cities cut. DB synced. See GJ0d section above for full cut list.
### Earth city rebalance (PR #133 review, user-approved)
Sydney → Lagos and London → Brussels per PR #133 review, user-approved rebalance. Brussels chosen for future EU-capital setting fit over London's legacy weight; Lagos chosen for African representation. DB re-synced. Final 11: Brussels, Istanbul, Moscow, New York, São Paulo, Cairo, Lagos, Delhi, Tokyo, Beijing, Singapore.
**Total name edits across all bodies:** 23 (non-Sol) + 33 (Sol) = 56 feature renames.
**Earth city cut:** 39 removed.
Intentional same-body cross-feature stem dups (quality arcs) now visible in audit output for:
- GJ144d: Rán- (city + river), Seter- (mountain + ocean)
- GJ144e: Veth- (river + ocean), Ash- (city + river)
- GJ244Ad: Charter- (mountain + ocean), Concord- (ocean + POI), Veil- (ocean + POI)
- GJ380c: Aldren- (city + river + POI, intentional), Durneth- (river + mountain)
- GJ699b: Capitol- (city + river + ocean), Meridian- (river + ocean)
- GJ0e: Hellas- (city + ocean), Chryse- (city + POI)
- GJ0f-2: Conamara- (city + mountain + POI), Pwyll- (city + POI)
Spot-check verification queries:
```bash
SR_DB_PATH="$(pwd)/server/data/systems.db" tooling/db/sqlite-query \
"SELECT local_id, name FROM atlas_rivers WHERE body_id = 'GJ144d'"
# Should show "Randalfoss" not "Aldren Pass"
SR_DB_PATH="$(pwd)/server/data/systems.db" tooling/db/sqlite-query \
"SELECT local_id, name FROM atlas_cities WHERE body_id = 'GJ71d'"
# Should show "The Praxis" not "Concordia Hall"
SR_DB_PATH="$(pwd)/server/data/systems.db" tooling/db/sqlite-query \
"SELECT local_id, name FROM atlas_mountain_ranges WHERE body_id = 'GJ244Ad'"
# Should show "The Statute" and "Charter Spur" not "Sanction Ridge" and "Keel Ridge"
SR_DB_PATH="$(pwd)/server/data/systems.db" tooling/db/sqlite-query \
"SELECT local_id, name FROM atlas_mountain_ranges WHERE body_id = 'GJ699b-1'"
# Should show "Verada Scarp", "Barnard Heights", "Keystone Scarp", etc. — no street addresses
```
---
## Files changed
- `tooling/planet-gen/atlas_cohesion_audit.py` (new)
- `tooling/planet-gen/apply_name_fixes.py` (new — extended with all 6 systems)
- `tooling/planet-gen/refine_log_849.md` (this file)
- `tooling/planet-gen/sol_markers/luna_features.json` (added cities)
- `tooling/planet-gen/sol_markers/mars_features.json` (added cities)
- `tooling/planet-gen/sol_markers/outer_features.json` (added Europa cities)
- `wiki/star-systems/GJ-144/bodies/GJ144d/markers.json` (4 edits)
- `wiki/star-systems/GJ-144/bodies/GJ144e/markers.json` (9 edits)
- `wiki/star-systems/GJ-71/bodies/GJ71c/markers.json` (1 edit)
- `wiki/star-systems/GJ-71/bodies/GJ71d/markers.json` (2 edits)
- `wiki/star-systems/GJ-244A/bodies/GJ244Ad/markers.json` (5 edits)
- `wiki/star-systems/GJ-380/bodies/GJ380c/markers.json` (2 edits)
- `wiki/star-systems/GJ-699/bodies/GJ699b/markers.json` (8 edits)
- `wiki/star-systems/GJ-699/bodies/GJ699b-1/markers.json` (8 edits)
- `tooling/planet-gen/sol_name_fixes.py` (new — names 33 null-name Sol features)
- `wiki/star-systems/GJ-0/bodies/GJ0d/markers.json` (1 ocean + 3 rivers named; 39 cities cut → 11)
- `wiki/star-systems/GJ-0/bodies/GJ0d-1/markers.json` (24 mountain ranges named)
- `wiki/star-systems/GJ-0/bodies/GJ0e/markers.json` (4 mountain ranges named)
- `wiki/star-systems/GJ-0/bodies/GJ0f-2/markers.json` (1 mountain range named)
- `server/data/systems.db` (synced: GJ144d, GJ144e, GJ71c, GJ71d, GJ71d-1, GJ244Ad, GJ380c, GJ699b, GJ699b-1, GJ0d, GJ0d-1, GJ0e, GJ0f-2 — all complete)
+14 -6
View File
@@ -231,18 +231,26 @@ from tooling.core.command import MARKER
from tooling.main import DOMAINS, _load_domain
report = []
for name in sorted(DOMAINS):
group = _load_domain(name)
ctx = None
for verb in group.list_commands(ctx):
cmd = group.get_command(ctx, verb)
def walk(name, group, prefix):
# A nested group (`atlas planet`) is not a verb: its callback only keeps it
# a group. Recurse into it instead, so the verbs underneath are held to the
# contract too -- a one-level walk reported the group and skipped all ten.
for verb in group.list_commands(None):
cmd = group.get_command(None, verb)
if hasattr(cmd, "list_commands"):
walk(name, cmd, prefix + verb + " ")
continue
callback = getattr(cmd, "callback", None)
report.append({
"domain": name,
"verb": verb,
"verb": prefix + verb,
"decorated": bool(getattr(callback, MARKER, False)),
"help": (cmd.help or cmd.short_help or "").strip(),
})
for name in sorted(DOMAINS):
walk(name, _load_domain(name), "")
print(json.dumps(report))
"""
result = subprocess.run(
@@ -6,7 +6,7 @@ The 1024×512 elevation bump (D-202 amended) claims to be deterministic — the
means a full regeneration. This asserts that simulating the same body twice
yields a bit-identical elevation array.
Run: uv run python tooling/planet-gen/test_sim_determinism.py
Run: .venv/bin/python tooling/test_planet_determinism.py
Exit: 0 = deterministic, 1 = drift detected, 2 = could not find a test body.
"""
import hashlib
@@ -14,13 +14,13 @@ import sqlite3
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "tooling" / "planet-gen"))
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
import numpy as np # noqa: E402
from body_definition_parser import parse_system # noqa: E402
from planet_simulation import GRID_H, GRID_W, simulate # noqa: E402
from tooling.domains.atlas.planet.body_definition_parser import parse_system # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import GRID_H, GRID_W, simulate # noqa: E402
def _elev_hash(terrain) -> str:
@@ -11,16 +11,16 @@ original authored constants were tuned at. See the module docstring on
Pure arithmetic (no numpy/scipy dependency) stdlib `unittest` only. Run
directly or via `make test-tooling`:
python3 tooling/planet-gen/test_oasis_ring_scaling.py
.venv/bin/python tooling/test_planet_oasis_rings.py
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import GRID_W, oasis_ring_iterations # noqa: E402
from tooling.domains.atlas.planet.planet_simulation import GRID_W, oasis_ring_iterations # noqa: E402
class OasisRingScalingTests(unittest.TestCase):
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""`atlas planet` declares its options twice — this stops them drifting (T-1288).
The router restates ten argument surfaces that already exist in the modules'
own argparse parsers. That duplication buys real `--help` for an agent, which
a passthrough could not, but it creates the obvious failure: the router grows
an option the module has never heard of, and the mismatch only shows up when
someone runs the command with that flag.
So every option the router declares is handed to the module's own parser here.
A parser rejects an unknown option with SystemExit(2), which is exactly the
signal wanted — no output comparison, no fixtures, no running the generators.
Nothing here executes a generator. Each parser is invoked directly, so a full
pass costs milliseconds and never touches the atlas DB.
Run: python3 tooling/test_planet_router.py
"""
from __future__ import annotations
import argparse
import contextlib
import io
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))
import typer # noqa: E402
from tooling.domains.atlas.planet import router as planet_router # noqa: E402
# verb -> (module attribute, positional arguments the parser requires)
IMPLEMENTATIONS = {
"generate": ("generate", ["body.json"]),
"batch": ("batch", []),
"scaffold": ("scaffold_bodies", ["index.json"]),
"import-heightmaps": ("import_heightmaps", []),
"import-provinces": ("import_province_boundaries", []),
"terrain-reference": ("populate_terrain_reference", []),
"sol-import": ("sol_import", []),
"sol-name-fixes": ("sol_name_fixes", []),
"audit": ("atlas_cohesion_audit", []),
"quality": ("atlas_quality_analysis", []),
}
# A representative value per click param type, so the parser sees a well-formed
# pair. Keyed on the type's `name` because these are ParamType INSTANCES, not
# classes — keying on the class matches nothing and every int option then gets
# the string "x" and reads as a parser rejection.
SAMPLE = {"int": "4", "str": "x", "path": "x", "filename": "x", "float": "1.0"}
# Options whose module-side parser restricts the value. The router cannot send
# a placeholder to these, so the test sends a real one.
CONSTRAINED = {
("generate", "--render-mode"): "cartographic",
("sol-import", "--render-mode"): "cartographic",
}
def _router_options(verb: str) -> list[tuple[str, type]]:
"""The long options the router declares for a verb, with their types."""
command = typer.main.get_command(planet_router.app).commands[verb] # type: ignore[attr-defined]
found: list[tuple[str, type]] = []
for param in command.params:
for opt in getattr(param, "opts", []):
if opt.startswith("--") and opt != "--help":
found.append((opt, param.type))
return found
class _Parsed(BaseException):
"""Raised the instant a parser accepts, to stop before any work happens."""
def _parser_accepts(module, argv: list[str]) -> tuple[bool, str]:
"""Feed argv to the module's own parser, and stop the moment it accepts.
`main()` builds its parser and then does the work, so simply calling it
would generate planets. Patching `parse_args` lets the module construct its
real parser — the thing under test — and aborts on the line after it
succeeds. A rejection still raises SystemExit(2) from inside argparse.
Found the hard way: the first version of this test called `main()` and let
it run, and spent two minutes generating bodies for GJ_1005A before it was
stopped.
"""
real = argparse.ArgumentParser.parse_args
def stop_after_parsing(self, args=None, namespace=None):
real(self, args, namespace)
raise _Parsed
buffer = io.StringIO()
argparse.ArgumentParser.parse_args = stop_after_parsing
try:
with contextlib.redirect_stderr(buffer), contextlib.redirect_stdout(buffer):
module.main(argv)
except _Parsed:
return True, ""
except SystemExit as exc:
if exc.code == 2: # argparse's "bad arguments"
return False, buffer.getvalue().strip()
return True, ""
except BaseException:
# Failed before reaching parse_args — an import guard, a missing DB.
# Not this test's business either way.
return True, ""
finally:
argparse.ArgumentParser.parse_args = real
return True, ""
def test_every_option_is_known_to_its_parser(failures: list[str]) -> None:
import importlib
for verb, (module_name, positionals) in IMPLEMENTATIONS.items():
module = importlib.import_module(f"tooling.domains.atlas.planet.{module_name}")
for opt, param_type in _router_options(verb):
argv = list(positionals)
argv.append(opt)
type_name = getattr(param_type, "name", "text")
if type_name != "boolean":
argv.append(CONSTRAINED.get((verb, opt), SAMPLE.get(type_name, "x")))
ok, err = _parser_accepts(module, argv)
if not ok:
failures.append(
f"`reach atlas planet {verb} {opt}` — {module_name}.py's parser "
f"does not accept it: {err.splitlines()[-1] if err else 'rejected'}"
)
def test_every_verb_has_an_implementation(failures: list[str]) -> None:
"""A verb missing from IMPLEMENTATIONS would be silently unchecked."""
declared = set(typer.main.get_command(planet_router.app).commands) # type: ignore[attr-defined]
covered = set(IMPLEMENTATIONS)
for verb in sorted(declared - covered):
failures.append(
f"`reach atlas planet {verb}` is not in IMPLEMENTATIONS — add it, or "
"its options are never checked against a parser"
)
for verb in sorted(covered - declared):
failures.append(f"IMPLEMENTATIONS names '{verb}', which the router does not declare")
def test_flags_builder_drops_defaults(failures: list[str]) -> None:
"""None and False must not reach argv — the module owns its defaults."""
built = planet_router._flags(system=None, force=False, body="GJ380c", limit=7, dry_run=True)
if "--system" in built or "--force" in built:
failures.append(f"_flags passed an unset option through: {built}")
if built != ["--body", "GJ380c", "--limit", "7", "--dry-run"]:
failures.append(f"_flags built unexpected argv: {built}")
# sol-import's --body is action="append"; a list must repeat the flag, or
# `--body GJ0d --body GJ0e` silently keeps only one of them.
repeated = planet_router._flags(body=["GJ0d", "GJ0e"])
if repeated != ["--body", "GJ0d", "--body", "GJ0e"]:
failures.append(f"_flags did not repeat a list option: {repeated}")
def main() -> int:
failures: list[str] = []
test_every_verb_has_an_implementation(failures)
test_flags_builder_drops_defaults(failures)
test_every_option_is_known_to_its_parser(failures)
if failures:
print("test_planet_router: FAIL", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
total = sum(len(_router_options(v)) for v in IMPLEMENTATIONS)
print(
f"test_planet_router: OK — {len(IMPLEMENTATIONS)} verbs, {total} options, "
"every one accepted by the module's own parser"
)
return 0
if __name__ == "__main__":
sys.exit(main())