Files
settled-reach/tooling/domains/atlas/planet/batch.py
T
jpmschweitzerandClaude Opus 5.5 6fb0ba0e3d chore(tooling): T-1272 + T-1274 — close E3: no hyphens left, and the lint ignores come off
T-1272 (a verification, as rescoped). No directory Python imports carries a
hyphen any more. The hyphenated script trees were emptied by the per-domain
moves, not renamed. What still has a hyphen is never imported: the three Rust
crates, and the provenance under tooling/archive/, which has no __init__.py.
CLAUDE.md and DEVOPS still pointed at tooling/db/, and pyproject still
predicted the rename; all three fixed.

T-1274. E402, E702 and F841 were ignored for the whole tree from T-1066 on
(43 / 41 / 21 violations). All three are back on:

- E402: the planet modules' imports only sat below their path constants
  because they used to follow a sys.path insert, gone since T-1288. Hoisted.
  The Blender payloads keep a per-file exception, because they extend
  sys.path under Blender's own Python.
- E702: the paired component assignments in three planet maths files are
  deliberate, so they get a per-file exception scoped to those files.
- F841: 10 dead locals removed from live code, each checked for side effects
  first; logo_uv keeps its call, which creates the UV layer.
- tooling/archive/ is excluded: it is provenance, and "fixing" a one-shot
  falsifies the record of what actually ran.

Evidence the lint is real: violations fed through stdin fire in a domain
module, and E402 stays quiet only on a payload path. Evidence nothing moved:
globe renders are pixel-identical before and after for an oceanic, a frozen
and a gas-giant body, and the ledger edit was regenerated (stamp fresh,
generated_brands.toml unchanged).

One finding, noted in the code rather than fixed: planet_renderer computed an
oblate-spheroid ray scale and never used it, so `oblateness` shapes no globe.
Wiring it in would change every globe render; that is a decision to make
deliberately, not a lint fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:05:07 +02:00

559 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Batch planet generation — process all systems unattended.
Walks wiki/star-systems/, scaffolds body index.md files where missing,
then generates heightmap + globe + terrain data for every body.
Usage:
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
- asteroid_belt, oort_cloud (no renderable surface)
- Bodies that already have globe.png (use --force to regenerate)
Error handling:
- Errors are printed to console and logged to /tmp/planet-gen-errors.log
- Batch aborts if error rate exceeds 50% of attempted bodies
"""
import argparse
import json
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
import numpy as np
import yaml
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
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.
# Systems to skip in batch mode (require manual handling)
SKIP_SYSTEMS = {"GJ-0"}
LOG_PATH = Path("/tmp/planet-gen-errors.log")
# ─────────────────────────────────────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────────────────────────────────────
def _log_error(system_id: str, body_id: str, error: str, tb: str):
"""Append error to log file with context."""
with open(LOG_PATH, "a") as f:
f.write(f"\n{'='*72}\n")
f.write(f" time: {datetime.now().isoformat()}\n")
f.write(f" system: {system_id}\n")
f.write(f" body: {body_id}\n")
f.write(f" error: {error}\n")
f.write(f" traceback:\n{tb}\n")
# ─────────────────────────────────────────────────────────────────────────────
# System helpers
# ─────────────────────────────────────────────────────────────────────────────
def _read_system_name(index_md: Path) -> str:
"""Extract the system name from the # heading in index.md."""
for line in index_md.read_text().splitlines()[:5]:
if line.startswith("# "):
return line[2:].strip()
return index_md.parent.name
def _read_frontmatter(md_path: Path) -> dict:
"""Read YAML frontmatter from a body index.md."""
content = md_path.read_text()
if not content.startswith("---"):
return {}
try:
end = content.index("---", 3)
return yaml.safe_load(content[3:end]) or {}
except (ValueError, yaml.YAMLError):
return {}
# ─────────────────────────────────────────────────────────────────────────────
# Validation (dry-run)
# ─────────────────────────────────────────────────────────────────────────────
REQUIRED_FIELDS = ["id", "planet_class", "seed", "star", "orbit", "physical"]
REQUIRED_STAR = ["type", "luminosity_solar"]
REQUIRED_ORBIT = ["distance_au", "period_days", "axial_tilt_deg"]
REQUIRED_PHYSICAL = ["gravity_g", "atmosphere"]
REQUIRED_TERRAIN = ["land_fraction", "tectonics"]
def _validate_body_def(bd: dict, body_dir: Path) -> list:
"""Validate a body definition. Returns list of error strings."""
errors = []
bid = bd.get("id", "?")
for f in REQUIRED_FIELDS:
if f not in bd:
errors.append(f"{bid}: missing top-level field '{f}'")
star = bd.get("star", {})
for f in REQUIRED_STAR:
if f not in star:
errors.append(f"{bid}: missing star.{f}")
orbit = bd.get("orbit", {})
for f in REQUIRED_ORBIT:
v = orbit.get(f)
if v is None or v == "rand":
errors.append(f"{bid}: orbit.{f} is {v!r} — must be resolved (not 'rand')")
elif isinstance(v, (int, float)) and v <= 0:
errors.append(f"{bid}: orbit.{f} = {v} — must be positive")
phys = bd.get("physical", {})
for f in REQUIRED_PHYSICAL:
if f not in phys:
errors.append(f"{bid}: missing physical.{f}")
seed = bd.get("seed")
if seed is None or not isinstance(seed, int):
errors.append(f"{bid}: seed must be an integer, got {seed!r}")
pclass = bd.get("planet_class", "")
valid_classes = {"temperate", "oceanic", "forest", "arid", "frozen",
"volcanic", "barren", "gas_giant", "gas_giant_ringed",
"cold_arid", "hot_arid", "tropical", "boreal",
"temperate_terminator"}
if pclass not in valid_classes:
errors.append(f"{bid}: planet_class '{pclass}' not in {valid_classes}")
is_gas = pclass in ("gas_giant", "gas_giant_ringed")
terrain = bd.get("terrain", {})
if not is_gas:
for f in REQUIRED_TERRAIN:
if f not in terrain:
errors.append(f"{bid}: missing terrain.{f}")
lf = terrain.get("land_fraction")
if lf is not None and (lf < 0 or lf > 1):
errors.append(f"{bid}: terrain.land_fraction = {lf} — must be [0, 1]")
return errors
# ─────────────────────────────────────────────────────────────────────────────
# Scaffold
# ─────────────────────────────────────────────────────────────────────────────
def _scaffold_system(system_dir: Path, overrides: dict) -> list:
"""Scaffold body index.md files for one system. Returns body IDs created."""
index_md = system_dir / "index.md"
if not index_md.exists():
return []
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"
created = []
for bd in body_defs:
body_dir = bodies_dir / bd["id"]
body_index = body_dir / "index.md"
if body_index.exists():
continue
body_dir.mkdir(parents=True, exist_ok=True)
fm = _body_to_frontmatter(bd)
prose = _body_prose(bd, system_dir)
body_index.write_text(f"---\n{fm}\n---\n\n{prose}")
created.append(bd["id"])
return created
# ─────────────────────────────────────────────────────────────────────────────
# Generate
# ─────────────────────────────────────────────────────────────────────────────
def _save_img(img, target: Path):
"""Write image directly — resume logic handles incomplete outputs."""
img.save(str(target), format="PNG")
def _save(data, target: Path, save_fn):
"""Write data directly — resume logic handles incomplete outputs."""
save_fn(data, str(target))
def _is_complete(body_dir: Path, is_gas: bool) -> bool:
"""Check if all expected outputs exist (for resume support)."""
if not (body_dir / "globe.png").exists():
return False
if not is_gas:
for f in ("heightmap.png", "terrain.npz", "markers.json"):
if not (body_dir / f).exists():
return False
return True
def _generate_body_from_dir(body_dir: Path, hmap_w: int, hmap_h: int,
globe_size: int, force: bool) -> str:
"""
Generate assets for one body.
Returns: "generated", "skipped", or "error"
"""
index_md = body_dir / "index.md"
if not index_md.exists():
return "skipped"
bd = _read_frontmatter(index_md)
if not bd or "id" not in bd or "planet_class" not in bd:
return "skipped"
body_id = bd["id"]
name = bd.get("name") or body_id
planet_class = bd.get("planet_class", "")
is_gas = planet_class in ("gas_giant", "gas_giant_ringed")
# Resume: skip if all outputs exist (unless --force)
if not force and _is_complete(body_dir, is_gas):
return "skipped"
t0 = time.time()
# Simulate
terrain = simulate(bd)
is_gas = not terrain # re-check from actual simulation result
# Heightmap — atomic write
if not is_gas:
hmap_img = render_heightmap(bd, terrain,
out_w=hmap_w, out_h=hmap_h,
chrome=False)
_save_img(hmap_img, body_dir / "heightmap.png")
# Globe — atomic write
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")
# Terrain data — atomic write
if not is_gas:
save_dict = {}
for key in ("elevation", "temperature", "moisture", "hillshade",
"biome", "surface_water", "river_grid"):
if key in terrain:
save_dict[key] = terrain[key]
save_dict["sea_level"] = np.array([terrain["sea_level"]])
np.savez_compressed(str(body_dir / "terrain.npz"), **save_dict)
# 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())}"
console.event(f" {body_id:20s} ({name:20s}) {elapsed:5.1f}s {kind}")
return "generated"
# ─────────────────────────────────────────────────────────────────────────────
# 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)")
parser.add_argument("--body", help="Process only this body (requires --system)")
parser.add_argument("--scaffold-only", action="store_true")
parser.add_argument("--generate-only", action="store_true")
parser.add_argument("--overrides", help="Per-body overrides JSON")
parser.add_argument("--force", action="store_true",
help="Regenerate even if globe.png exists")
parser.add_argument("--dry-run", action="store_true",
help="Validate body definitions without generating")
parser.add_argument("--verify-determinism", type=int, metavar="N", default=0,
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(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():
raise ReachError(f"{wiki_systems} not found", fix="run from a settled-reach checkout — make reach-repoint")
overrides = {}
if args.overrides:
with open(args.overrides) as f:
overrides = json.load(f)
# Collect system directories
if args.system:
system_dirs = [wiki_systems / args.system]
if not system_dirs[0].exists():
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()
if d.is_dir() and (d / "index.md").exists()
])
# Clear error log
if LOG_PATH.exists():
LOG_PATH.unlink()
t_total = time.time()
total_scaffolded = 0
total_generated = 0
total_skipped = 0
total_errors = 0
total_attempted = 0
total_valid = 0
total_invalid = 0
error_rate_threshold = 0.50
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:
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:
console.event(f" {system_id} — skipped (manual)")
continue
system_name = _read_system_name(system_dir / "index.md")
# Count bodies before starting
bodies_dir_check = system_dir / "bodies"
if bodies_dir_check.exists():
n_bodies = sum(1 for d in bodies_dir_check.iterdir()
if d.is_dir() and (d / "index.md").exists())
else:
# Peek at the system table to estimate body count
try:
defs = parse_system(str(system_dir / "index.md"), overrides=overrides)
n_bodies = len(defs)
except Exception:
n_bodies = 0
console.event(f" {system_id} — {system_name} ({n_bodies} bodies)")
# ── Scaffold ─────────────────────────────────────────────────────
if not args.generate_only and not args.dry_run:
try:
created = _scaffold_system(system_dir, overrides)
if created:
total_scaffolded += len(created)
for bid in created:
console.event(f" scaffolded {bid}")
except Exception as e:
tb = traceback.format_exc()
console.event(f" SCAFFOLD ERROR: {e}")
_log_error(system_id, "*", str(e), tb)
# ── Validate / Generate ──────────────────────────────────────────
bodies_dir = system_dir / "bodies"
if not bodies_dir.exists():
if not args.scaffold_only and not args.dry_run:
# Try scaffold first if bodies dir doesn't exist
try:
_scaffold_system(system_dir, overrides)
except Exception:
pass
if not bodies_dir.exists():
continue
body_dirs = sorted(d for d in bodies_dir.iterdir() if d.is_dir())
if args.body:
body_dirs = [d for d in body_dirs if d.name == args.body]
for body_dir in body_dirs:
index_md = body_dir / "index.md"
if not index_md.exists():
continue
bd = _read_frontmatter(index_md)
if not bd or "id" not in bd:
continue
body_id = bd.get("id", "?")
body_name = bd.get("name") or body_id
if args.dry_run:
# Validate only
errors = _validate_body_def(bd, body_dir)
if errors:
total_invalid += 1
console.event(f" {body_id:20s} ({body_name:20s}) INVALID")
for err in errors:
console.event(f" - {err}")
else:
total_valid += 1
console.event(f" {body_id:20s} ({body_name:20s}) ok")
continue
if args.scaffold_only:
continue
# Generate with error handling
total_attempted += 1
try:
result = _generate_body_from_dir(
body_dir, hmap_w, hmap_h, args.globe_size, args.force)
if result == "generated":
total_generated += 1
elif result == "skipped":
total_skipped += 1
elif result == "error":
total_errors += 1
except Exception as e:
total_errors += 1
tb = traceback.format_exc()
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:
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")
console.event(f" Check {LOG_PATH} for details")
sys.exit(1)
elapsed = time.time() - t_total
console.event(f" Batch complete: {elapsed:.0f}s")
if args.dry_run:
console.event(f" valid: {total_valid}")
console.event(f" invalid: {total_invalid}")
else:
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:
console.event(f" error log: {LOG_PATH}")
# ── Determinism verification ─────────────────────────────────────────
if args.verify_determinism > 0:
_verify_determinism(wiki_systems, args.verify_determinism,
hmap_w, hmap_h, args.globe_size)
def _verify_determinism(wiki_systems: Path, n_samples: int,
hmap_w: int, hmap_h: int, globe_size: int):
"""Run N bodies twice, verify outputs are bit-identical."""
import hashlib
import tempfile
import shutil
console.event(f" Determinism verification — {n_samples} samples")
# Collect all body dirs that have been generated
all_body_dirs = []
for system_dir in wiki_systems.iterdir():
bodies = system_dir / "bodies"
if bodies.exists():
for bd in bodies.iterdir():
if bd.is_dir() and (bd / "globe.png").exists():
all_body_dirs.append(bd)
if not all_body_dirs:
console.event(" no generated bodies to verify")
return
rng = np.random.default_rng(42)
samples = rng.choice(len(all_body_dirs), min(n_samples, len(all_body_dirs)),
replace=False)
passed = 0
failed = 0
for idx in samples:
body_dir = all_body_dirs[idx]
bd = _read_frontmatter(body_dir / "index.md")
if not bd:
continue
body_id = bd["id"]
# Generate into a temp dir
tmp_dir = Path(tempfile.mkdtemp(prefix=f"detcheck_{body_id}_"))
tmp_body = tmp_dir / body_id
tmp_body.mkdir()
# Copy index.md so the generator can read it
shutil.copy2(body_dir / "index.md", tmp_body / "index.md")
try:
_generate_body_from_dir(tmp_body, hmap_w, hmap_h, globe_size, force=True)
except Exception as e:
console.event(f" {body_id}: generation failed — {e}")
shutil.rmtree(tmp_dir)
failed += 1
continue
# Compare file hashes
all_match = True
for fname in ("globe.png", "heightmap.png", "terrain.npz", "markers.json"):
orig = body_dir / fname
rerun = tmp_body / fname
if not orig.exists() and not rerun.exists():
continue
if not orig.exists() or not rerun.exists():
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:
console.event(f" {body_id}: {fname} — MISMATCH (orig={h1} rerun={h2})")
all_match = False
if all_match:
passed += 1
else:
failed += 1
shutil.rmtree(tmp_dir)
console.event(f" passed: {passed} failed: {failed}")
if failed > 0:
# 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__":
main()