#!/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()