Files
settled-reach/tooling/domains/atlas/planet/sol_data/mars.py
T
jpmschweitzerandClaude Opus 5.5 668772075c 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>
2026-09-23 16:08:02 +02:00

191 lines
8.0 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.
"""
Mars (GJ0e) terrain builder.
Data source:
- Elevation: MOLA MEGDR (Mars Orbiter Laser Altimeter)
PDS format, big-endian int16, metres relative to areoid.
Available at multiple resolutions. We use 4ppd (1440×720)
or 16ppd (5760×2880) — both small enough to download quickly.
Mars properties:
- Min elevation: ~-8200 m (Hellas Basin)
- Max elevation: ~21229 m (Olympus Mons)
- Polar ice caps: CO2 + water ice
- Thin atmosphere (6 mbar) — classified as "thin" in body_def
- Almost no liquid water (hydrosphere: "ice")
"""
import numpy as np
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
# PDS binary with no header (data starts at byte 0 for .img files)
MOLA_4PPD_URL = "https://pds-geosciences.wustl.edu/mgs/mgs-m-mola-5-megdr-l3-v1/mgsl_300x/meg004/megt90n000cb.img"
MOLA_4PPD_FILE = "mola_megdr_4ppd.img"
MOLA_4PPD_W = 1440
MOLA_4PPD_H = 720
# Alternative: 16ppd (5760 × 2880) for higher quality
MOLA_16PPD_URL = "https://pds-geosciences.wustl.edu/mgs/mgs-m-mola-5-megdr-l3-v1/mgsl_300x/meg016/megt90n000eb.img"
MOLA_16PPD_FILE = "mola_megdr_16ppd.img"
MOLA_16PPD_W = 5760
MOLA_16PPD_H = 2880
# Mars physical constants
MARS_MIN_ELEV_M = -8200.0 # Hellas Basin
MARS_MAX_ELEV_M = 21229.0 # Olympus Mons summit
# Real Mars temperatures — we don't fudge these. Mars colour comes from
# ferric biome classes (34/35/36) applied based on iron oxide substrate.
MARS_EQUATORIAL_TEMP_K = 215.0 # daytime average near equator
MARS_POLAR_TEMP_K = 150.0
MARS_OCEAN_FRACTION = 0.0 # no liquid water (ice only)
# Ferric biome class IDs (from biomes.toml)
FERRIC_DUST = 34
FERRIC_HIGHLAND = 35
FERRIC_LOWLAND = 36
def _load_mola(use_16ppd: bool = False) -> np.ndarray:
"""Load MOLA DEM, return elevation in metres."""
if use_16ppd:
url, filename, w, h = MOLA_16PPD_URL, MOLA_16PPD_FILE, MOLA_16PPD_W, MOLA_16PPD_H
else:
url, filename, w, h = MOLA_4PPD_URL, MOLA_4PPD_FILE, MOLA_4PPD_W, MOLA_4PPD_H
path = ensure_cached(url, filename)
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)
# MOLA nodata is typically 32767 or -32768
arr[arr > 30000] = 0.0
arr[arr < -30000] = 0.0
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."""
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 tooling.domains.atlas.planet.sol_data.shared import greenwich_to_dateline
mola_shifted = greenwich_to_dateline(mola_raw)
# Resample to grid
elevation_m = resample_to_grid(mola_shifted, GRID_H, GRID_W, order=1)
# Normalise to [0, 1]
elevation = normalize_01(elevation_m, MARS_MIN_ELEV_M, MARS_MAX_ELEV_M)
console.event(" elevation normalised")
# ── 2. Temperature ──────────────────────────────────────────────────
# Analytical: equatorial ~210K, polar ~150K, elevation lapse
temperature_K = temperature_grid_analytical(
base_T_K=MARS_EQUATORIAL_TEMP_K,
elevation=elevation,
lapse_rate_K_per_unit=30.0,
lat_gradient_K=60.0,
)
# Polar ice caps: very cold at high latitudes
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
lat_abs = np.abs(v - 0.5) * 2.0
polar_rows = lat_abs > 0.75
temperature_K[polar_rows, :] = np.minimum(temperature_K[polar_rows, :], 155.0)
console.event(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
# Mars has almost no moisture — thin atmosphere
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# Slight moisture near polar caps (water ice)
moisture[polar_rows, :] = 0.1
# ── 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 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
sea_level = _compute_sl(elevation, TERRAFORM_OCEAN_FRAC)
surface_water = elevation < sea_level
# Don't flood polar regions — those stay as ice caps, not lakes
surface_water[polar_rows, :] = False
n_water = int(surface_water.sum())
console.event(f" terraformed water: {n_water} cells "
f"(sea_level={sea_level:.4f})")
# ── 5. Biome classification ─────────────────────────────────────────
# Mars biome is built directly — compute_biome() would classify
# everything as ice at these temperatures.
biome = np.full((GRID_H, GRID_W), FERRIC_DUST, dtype=np.int8)
# Elevation-based ferric variation
biome[elevation > 0.55] = FERRIC_HIGHLAND # volcanic highlands
biome[elevation < 0.25] = FERRIC_LOWLAND # basin floors
# Polar ice caps
biome[polar_rows, :] = 17 # ice/snow
# Terraformed green fringe around water bodies — vegetation band
# where the thicker local atmosphere and water access allow plants.
# ~5 cell band around each water body.
veg_ring = binary_dilation(surface_water, iterations=5) & ~surface_water
# Don't put vegetation at poles
veg_ring[polar_rows, :] = False
biome[veg_ring] = 12 # shrubland (olive green — sparse terraformed vegetation)
# Inner vegetation ring (closer to water = lusher)
inner_ring = binary_dilation(surface_water, iterations=2) & ~surface_water
inner_ring[polar_rows, :] = False
biome[inner_ring] = 8 # temperate grassland (greener)
# Ocean depth bands for water bodies
if surface_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
biome[surface_water & (depth < 0.15)] = 2 # shallow
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1 # mid
biome[surface_water & (depth >= 0.50)] = 0 # deep
n_ice = int((biome == 17).sum())
n_ferric = int(((biome >= 34) & (biome <= 36)).sum())
n_veg = int(((biome == 8) | (biome == 12)).sum())
n_ocean = int(((biome >= 0) & (biome <= 2)).sum())
console.event(f" biomes: {len(np.unique(biome))} classes "
f"(ferric={n_ferric}, ice={n_ice}, veg={n_veg}, water={n_ocean})")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
# ── 6. Assemble ─────────────────────────────────────────────────────
return assemble_terrain(
elevation=elevation,
temperature_K=temperature_K,
moisture=moisture,
biome=biome,
surface_water=surface_water,
hillshade=hillshade,
rivers=[], # no rivers on Mars
sea_level=sea_level,
)