Files
settled-reach/tooling/planet-gen/planet_simulation.py
T
jpmschweitzerandClaude Fable 5 346d87df7a chore(meta): docs/build sweep + tooling test gate (T-1069, T-1066)
- make test-tooling: planet-gen determinism guard + import_economics
  --dry-run, wired into pre-push on TOOLING_CHANGED; ruff widened to
  E4/E7/E9/F/W (90 safe auto-fixes applied; E402/E702/F841 ignored with
  documented counts)
- one-generator reality fixed in DEVOPS.md, asset-pipeline rule, CLAUDE.md
  (import_economics sole generator since #951/D-223); dead check-protocol
  target deleted; DEVOPS hook/config sections rewritten from the actual
  hook sources; team-patterns gate description updated (client+tooling)
- project.yaml: 0.2.0 → 0.4.0 per the 0.{phase}.{n} scheme, description
  refreshed from the v0.1 Sova narration to cascade reality
- stale comment sweep: voxel.rs stub claims (all 8 families implemented),
  cascade.rs TODO recited to T-1044, main.rs D-192 handshake claim,
  relationships.rs/chunk_streaming.rs version targets → phase language
- gitignore: client/settings.db* e2e-run artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:22:55 +02:00

900 lines
38 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.
"""
planet_simulation.py
--------------------
Terrain simulation stack for the Settled Reach planet generator.
Consumes a body_definition dict (output of body_definition_parser.py)
and produces a terrain dict consumed by planet_renderer.render_globe().
Output terrain dict:
{
"elevation": float32 (H, W) [0, 1] normalised elevation
"temperature": float32 (H, W) [0, 1] 0=coldest, 1=hottest
"moisture": float32 (H, W) [0, 1] 0=driest, 1=wettest
"biome": int8 (H, W) biome class index
"surface_water": bool (H, W) ocean/lake mask
"hillshade": float32 (H, W) [0, 1] lighting from slope+aspect
"sea_level": float elevation threshold
}
Pipeline:
1. Elevation - continent mask + domain-warped FBM + tectonic ridges + erosion
2. Temperature - analytical formula: star + latitude + altitude
3. Moisture - Hadley cells + ocean proximity + rain shadow
4. Hillshade - surface normals from elevation gradient
5. Rivers - downhill carving from moisture-seeded sources
6. Biome - extended Whittaker lookup + modifier stack
Grid: 512 x 256 (longitude x latitude), equirectangular.
Row 0 = north pole, row 255 = south pole.
Col 0 = 180W, col 511 = 180E.
"""
import logging
import math
import numpy as np
from scipy.ndimage import gaussian_filter
from biome_config import (
WHITTAKER_TABLE, CLASS_T_BAND, EXOTIC_CLASSES, CRATER_SCALING,
)
log = logging.getLogger(__name__)
# Canonical heightmap grid (D-202 amended, #963): bumped to 1024×512 so the
# stored elevation has real mid-scale detail for the lower cascade layers. The
# elevation noise is resolution-independent (normalized coords + absolute
# frequencies), so a higher grid samples the SAME continuous terrain at finer
# density — features keep their physical size and generation stays deterministic.
# Pixel-unit operations (gaussian sigma, crater radii, filter windows) scale by
# `GRID_W / 512` so smoothing/morphology behave identically at any resolution.
GRID_W = 1024
GRID_H = 512
# ---------------------------------------------------------------------------
# Seeded RNG
# ---------------------------------------------------------------------------
def _rng(seed: int, salt: int = 0) -> np.random.Generator:
return np.random.default_rng(seed ^ (salt * 2654435761))
# ---------------------------------------------------------------------------
# Noise primitives
# ---------------------------------------------------------------------------
def _hash2(x: np.ndarray, y: np.ndarray, seed: int) -> np.ndarray:
s = np.int64(seed & 0xFFFF)
h = (x.astype(np.int64) * np.int64(1619) +
y.astype(np.int64) * np.int64(31337) +
s * np.int64(6971)) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
return (h & np.int64(0xFFFF)).astype(np.float32) / 65535.0
def _vnoise(u, v, freq, seed):
"""Standard 2D value noise — NOT seamless. Use _vnoise_s for longitude axis."""
uf = u * freq; vf = v * freq
x0 = np.floor(uf).astype(np.int32); y0 = np.floor(vf).astype(np.int32)
x1 = x0 + 1; y1 = y0 + 1
tx = uf - x0; ty = vf - y0
tx = tx * tx * (3.0 - 2.0 * tx)
ty = ty * ty * (3.0 - 2.0 * ty)
v00 = _hash2(x0, y0, seed); v10 = _hash2(x1, y0, seed)
v01 = _hash2(x0, y1, seed); v11 = _hash2(x1, y1, seed)
return (v00*(1-tx)*(1-ty) + v10*tx*(1-ty) +
v01*(1-tx)*ty + v11*tx*ty).astype(np.float32)
def _hash3(x, y, z, seed):
"""Hash for 3D integer coords."""
s = np.int64(seed & 0xFFFF)
h = (x.astype(np.int64) * np.int64(1619) +
y.astype(np.int64) * np.int64(31337) +
z.astype(np.int64) * np.int64(49979) +
s * np.int64(6971)) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
h = ((h >> 16) ^ h) * np.int64(0x45d9f3b) & np.int64(0xFFFFFFFF)
return (h & np.int64(0xFFFF)).astype(np.float32) / 65535.0
def _vnoise_seamless(u, v, freq, seed):
"""
Seamless value noise in the U (longitude) axis only.
Maps u -> (cos(u*2π), sin(u*2π)) before hashing, so the noise
field is periodic in U with period 1 — no seam at the date line.
V (latitude) is not periodic — poles are endpoints, not a loop.
"""
# Project U onto a circle: (cx, cy)
# Divide circle radius by 2π so one full revolution spans the same
# distance as freq units on the flat V axis — corrects aspect ratio.
angle = u * (2.0 * math.pi)
r = freq / (2.0 * math.pi)
cx = np.cos(angle) * r
cy = np.sin(angle) * r
vf = v * freq
# Integer lattice in 3D (cx, cy, vf)
x0 = np.floor(cx).astype(np.int32); x1 = x0 + 1
y0 = np.floor(cy).astype(np.int32); y1 = y0 + 1
z0 = np.floor(vf).astype(np.int32); z1 = z0 + 1
# Smoothstep weights
tx = cx - x0; tx = tx * tx * (3.0 - 2.0 * tx)
ty = cy - y0; ty = ty * ty * (3.0 - 2.0 * ty)
tz = vf - z0; tz = tz * tz * (3.0 - 2.0 * tz)
# Trilinear interpolation over 8 corners
v000 = _hash3(x0, y0, z0, seed); v100 = _hash3(x1, y0, z0, seed)
v010 = _hash3(x0, y1, z0, seed); v110 = _hash3(x1, y1, z0, seed)
v001 = _hash3(x0, y0, z1, seed); v101 = _hash3(x1, y0, z1, seed)
v011 = _hash3(x0, y1, z1, seed); v111 = _hash3(x1, y1, z1, seed)
return (v000*(1-tx)*(1-ty)*(1-tz) + v100*tx*(1-ty)*(1-tz) +
v010*(1-tx)*ty*(1-tz) + v110*tx*ty*(1-tz) +
v001*(1-tx)*(1-ty)*tz + v101*tx*(1-ty)*tz +
v011*(1-tx)*ty*tz + v111*tx*ty*tz).astype(np.float32)
def _fbm(u, v, seed, octaves=6, lacunarity=2.0, gain=0.50, base_freq=2.0):
"""FBM using seamless noise in U — no longitude seam."""
result = np.zeros_like(u, dtype=np.float32)
amp = 1.0; freq = base_freq; total = 0.0
rng = np.random.default_rng(seed)
for _ in range(octaves):
oct_seed = int(rng.integers(0, 0x7FFFFFFF))
result += amp * _vnoise_seamless(u, v, freq, oct_seed)
total += amp
amp *= gain; freq *= lacunarity
return result / (total + 1e-9)
def _domain_warp(u, v, seed, strength=0.35):
"""Domain warp using seamless FBM — preserves no-seam property."""
wu = _fbm(u + 1.7, v + 9.2, seed + 1, octaves=4) * 2.0 - 1.0
wv = _fbm(u + 8.3, v + 2.8, seed + 2, octaves=4) * 2.0 - 1.0
# Only warp u periodically — keep v warp non-periodic (poles stay poles)
return (u + wu * strength) % 1.0, np.clip(v + wv * strength * 0.5, 0.0, 1.0)
# ---------------------------------------------------------------------------
# Coordinate grids
# ---------------------------------------------------------------------------
def _make_grids():
u_1d = np.linspace(0, 1, GRID_W, dtype=np.float32)
v_1d = np.linspace(0, 1, GRID_H, dtype=np.float32)
u, v = np.meshgrid(u_1d, v_1d)
lat_frac = -(v - 0.5) * 2.0 # +1 = north, -1 = south
lon_frac = (u - 0.5) * 2.0
lat_rad = lat_frac * (math.pi / 2.0)
return u, v, lat_frac, lon_frac, lat_rad
# ---------------------------------------------------------------------------
# 1. Elevation
# ---------------------------------------------------------------------------
def _continent_mask(u, v, seed, land_fraction):
def _norm(a):
lo, hi = a.min(), a.max()
return (a - lo) / (hi - lo + 1e-9)
def _contrast(a, strength=3.0):
"""
S-curve contrast: pushes highs toward 1 and lows toward 0
regardless of the field mean. More reliable than power curves
which behave differently depending on the field's distribution.
strength controls steepness — higher = sharper separation.
"""
# Sigmoid centred at 0.5: f(x) = 1/(1+exp(-k*(x-0.5)))
k = strength * 8.0
return 1.0 / (1.0 + np.exp(-k * (a - 0.5)))
# Primary: large continental plates
wu1, wv1 = _domain_warp(u, v, seed, strength=0.45)
primary = _norm(_fbm(wu1, wv1, seed + 10, octaves=5, gain=0.58, base_freq=1.2))
# Secondary: independent medium-scale field.
# S-curve contrast gives reliable highs and lows regardless of seed.
wu2, wv2 = _domain_warp(u, v, seed + 11, strength=0.40)
sec_raw = _norm(_fbm(wu2, wv2, seed + 20, octaves=5, gain=0.55, base_freq=1.8))
secondary = _contrast(sec_raw, strength=2.5)
# Rift: anisotropic thin elongated features
wu3, wv3 = _domain_warp(u, v, seed + 17, strength=0.30)
rift = _norm(_fbm(wu3, wv3 * 0.35, seed + 30, octaves=4, gain=0.52, base_freq=3.5))
# Multiplicative gate: secondary zeroes kill primary → ocean channels
separated = primary * (0.4 + secondary * 0.6)
combined = separated * 0.82 + (rift - 0.5) * 0.18
return _norm(combined).astype(np.float32)
def _tectonic_ridges(u, v, seed, n_plates=8):
rng = _rng(seed, 99)
px = rng.uniform(0, 1, n_plates).astype(np.float32)
py = rng.uniform(0, 1, n_plates).astype(np.float32)
H, W = u.shape
# Domain-warp coords before Voronoi — bends ridge positions into curves
wu1 = _fbm(u * 1.5 + 3.1, v * 1.5 + 7.4, seed + 201, octaves=3,
gain=0.55, base_freq=1.8) * 2.0 - 1.0
wv1 = _fbm(u * 1.5 + 8.6, v * 1.5 + 2.2, seed + 202, octaves=3,
gain=0.55, base_freq=1.8) * 2.0 - 1.0
wu2 = _fbm(u * 4.0 + 1.3, v * 4.0 + 5.7, seed + 203, octaves=2,
gain=0.50, base_freq=3.5) * 2.0 - 1.0
wv2 = _fbm(u * 4.0 + 6.1, v * 4.0 + 0.9, seed + 204, octaves=2,
gain=0.50, base_freq=3.5) * 2.0 - 1.0
uw = (u + wu1 * 0.22 + wu2 * 0.08) % 1.0
vw = np.clip(v + wv1 * 0.18 + wv2 * 0.06, 0.0, 1.0)
dist1 = np.full((H, W), np.inf, dtype=np.float32)
dist2 = np.full((H, W), np.inf, dtype=np.float32)
for i in range(n_plates):
du = np.minimum(np.abs(uw - px[i]), 1.0 - np.abs(uw - px[i]))
dv = np.abs(vw - py[i])
d = np.sqrt(du**2 + dv**2)
mask = d < dist1
dist2 = np.where(mask, dist1, np.minimum(dist2, d))
dist1 = np.where(mask, d, dist1)
# Two ridge widths: broad ranges + sharp collision zones
broad = np.exp(-((dist2 - dist1) / 0.06) ** 2) * 0.5
sharp = np.exp(-((dist2 - dist1) / 0.025) ** 2) * 1.0
ridge_raw = np.clip(broad + sharp, 0, 1)
# Amplitude variation along ridge
ridge_noise = _fbm(u, v, seed + 50, octaves=4, gain=0.55, base_freq=4.0)
# Fracture zones — cross-cutting features (transform faults, rift valleys)
# Anisotropic: stretch u relative to v for elongated cross features
fracture = _fbm(u * 0.4, v, seed + 77, octaves=3, gain=0.6, base_freq=6.0)
fracture = np.clip(fracture - 0.55, 0, 1) * 2.0
return np.clip(ridge_raw * (0.35 + 0.65 * ridge_noise)
+ fracture * 0.20, 0, 1).astype(np.float32)
def _erode(terrain, passes, seed):
result = terrain.copy()
# Resolution scale: smoothing radii and the per-pixel slope (which halves as
# the grid doubles, since np.gradient is in pixel units) scale with width so
# erosion behaves identically at any GRID size.
scale = result.shape[1] / 512.0
for _ in range(passes):
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
smooth = gaussian_filter(result, sigma=1.2 * scale)
weight = np.clip(slope * 6.0 * scale, 0.0, 1.0)
result = result * (1.0 - weight * 0.35) + smooth * (weight * 0.35)
gy, gx = np.gradient(result)
slope = np.sqrt(gx**2 + gy**2)
flow = gaussian_filter(slope, sigma=3.0 * scale)
flow = (flow - flow.min()) / (flow.max() - flow.min() + 1e-9)
result = result - flow * 0.06
return np.clip(result, 0.0, 1.0)
def compute_elevation(body_def, u, v, lat_frac):
seed = body_def["seed"]
planet_class = body_def["planet_class"].replace("_ringed", "")
land_frac = body_def["terrain"]["land_fraction"]
tectonics = body_def["terrain"].get("tectonics", "active")
plate_map = {"extreme": 12, "active": 8, "low": 5, "none": 3}
erosion_map = {"extreme": 1, "active": 3, "low": 4, "none": 2}
n_plates = plate_map.get(tectonics, 8)
erosion_p = erosion_map.get(tectonics, 3)
ocean_pct = (1.0 - land_frac) * 100.0
detail = _fbm(u, v, seed + 300, octaves=5, gain=0.45, base_freq=4.0)
if tectonics == "none":
# No tectonic activity: gentle base terrain, no ridges, no continents.
# Craters dominate on these worlds.
base = _fbm(u, v, seed + 100, octaves=4, gain=0.50, base_freq=1.5)
elev = base * 0.60 + detail * 0.40
else:
# Tectonic worlds: continent mask + ridges scaled by activity level.
cont = _continent_mask(u, v, seed, land_frac)
ridges = _tectonic_ridges(u, v, seed, n_plates=n_plates)
sea_level_est = float(np.percentile(cont, ocean_pct))
land_mask = cont >= sea_level_est
# Ridge prominence scales with tectonic activity
ridge_weight = {"low": 0.12, "active": 0.25, "extreme": 0.38}
rw = ridge_weight.get(tectonics, 0.25)
elev = (cont * (0.80 - rw)
+ ridges * rw * land_mask
+ detail * 0.20)
# Craters happen everywhere. Atmosphere controls how many impactors
# survive entry; tectonics controls how many craters get resurfaced.
# Both reduce density, neither toggles craters off entirely.
crater_factor = (CRATER_SCALING["atmosphere"].get(body_def["physical"]["atmosphere"], 0.25)
* CRATER_SCALING["tectonics"].get(tectonics, 0.3))
if crater_factor > 0.02:
rng = _rng(seed, 77)
base_count = CRATER_SCALING["base_count"]
n_craters = max(5, int(base_count * crater_factor))
cy_c = rng.uniform(0, GRID_H, n_craters).astype(np.float32)
cx_c = rng.uniform(0, GRID_W, n_craters).astype(np.float32)
# Power-law: most craters are small, a few large. Radii are in pixels,
# so scale with resolution to keep craters the same physical size.
raw_sizes = rng.power(0.4, n_craters) # skewed toward 0
sizes = ((2 + raw_sizes * 28) * (GRID_W / 512.0)).astype(np.float32)
# Depth scales with crater factor — eroded worlds have shallower craters
depth_scale = 0.5 + 0.5 * crater_factor
depths = ((0.05 + raw_sizes * 0.20) * depth_scale).astype(np.float32)
rows = np.arange(GRID_H, dtype=np.float32)
cols = np.arange(GRID_W, dtype=np.float32)
rr, cc = np.meshgrid(rows, cols, indexing='ij')
# Latitude correction: scale longitude distance by cos(lat) so
# craters are circular on the sphere, not stretched at the poles.
lat_rad = (0.5 - rr / GRID_H) * math.pi # +pi/2 at north, -pi/2 at south
cos_lat = np.cos(lat_rad)
cos_lat = np.clip(cos_lat, 0.1, 1.0) # avoid division issues at poles
craters = np.zeros_like(elev)
for i in range(n_craters):
dy = rr - cy_c[i]
dx = cc - cx_c[i]
# Wrap longitude for craters near the date line
dx = np.minimum(np.abs(dx), GRID_W - np.abs(dx))
# Scale dx by cos(lat) at the crater center
center_lat = (0.5 - cy_c[i] / GRID_H) * math.pi
dx_scaled = dx / max(math.cos(center_lat), 0.1)
d = np.sqrt(dy**2 + dx_scaled**2)
r = sizes[i]
dep = depths[i]
# Crater profile: flat floor inside 0.6r, raised rim at 0.9-1.1r,
# smooth falloff outside. More realistic than gaussian dimple.
floor = np.clip(1.0 - d / (r * 0.6), 0, 1)
rim = np.exp(-((d - r) / (r * 0.25))**2)
craters -= dep * floor * 0.8 # excavate floor
craters += dep * rim * 0.3 # raise rim
elev = elev + craters
elev = np.clip(elev, 0.0, None) # floor at 0
elif planet_class == "frozen":
elev = gaussian_filter(elev, sigma=1.5 * (GRID_W / 512.0)).astype(np.float32)
elif planet_class == "volcanic":
erosion_p = max(1, erosion_p - 1)
elev = _erode(elev, passes=erosion_p, seed=seed)
lo, hi = elev.min(), elev.max()
elev = (elev - lo) / (hi - lo + 1e-9)
sea_level = float(np.percentile(elev, ocean_pct))
# Polar ice — smooth land elevation toward a low plateau at high latitudes.
# Only applies when there's an atmosphere to deliver precipitation/ice.
# Airless bodies have no polar caps — cold rock stays rock.
hydro = body_def.get("environment", {}).get("hydrosphere", "ocean")
atmo = body_def["physical"]["atmosphere"]
has_polar_ice = atmo not in ("none",) and hydro not in ("none", "subsurface")
if has_polar_ice:
ice_lat = body_def["terrain"].get("polar_ice_lat", 0.80)
lat_abs = np.abs(lat_frac)
ice_blend = np.clip((lat_abs - ice_lat) / (1.0 - ice_lat + 0.01), 0, 1)
if planet_class == "frozen":
ice_blend = np.clip(ice_blend * 2.0, 0, 1)
land_mask = elev >= sea_level
ice_target = sea_level + 0.05
elev = np.where(
land_mask,
elev * (1.0 - ice_blend * 0.6) + ice_target * (ice_blend * 0.6),
elev)
elev = np.clip(elev, 0.0, 1.0).astype(np.float32)
sea_level = float(np.percentile(elev, ocean_pct))
# Dry worlds (no/subsurface hydrosphere): low elevation is dry basin, not ocean.
hydro = body_def.get("environment", {}).get("hydrosphere", "ocean")
if hydro in ("none", "subsurface"):
surf_water = np.zeros_like(elev, dtype=bool)
else:
surf_water = elev < sea_level
return elev, sea_level, surf_water
# ---------------------------------------------------------------------------
# 2. Temperature
# ---------------------------------------------------------------------------
# Stellar luminosity relative to Sol (approximate midpoint per spectral type)
STAR_LUMINOSITY = {
"O": 100000.0, "B": 1000.0, "A": 10.0,
"F": 2.5, "G": 1.0, "K": 0.4, "M": 0.04,
}
# CLASS_T_BAND loaded from biomes.toml via biome_config
def compute_temperature(body_def, elevation, sea_level, lat_frac):
star_type = body_def["star"]["type"]
distance_au = body_def["orbit"]["distance_au"]
axial_tilt = body_def["orbit"]["axial_tilt_deg"]
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
geothermal = body_def.get("environment", {}).get("geothermal_flux", "low")
# Equilibrium temperature — descriptor-anchored.
#
# We compute the raw stellar physics (Stefan-Boltzmann) to get a
# physically grounded value, then clamp it to the temperature band
# appropriate for the planet_class. This ensures the wiki's descriptors
# (temperate, frozen, arid…) are always honoured even when orbital
# parameters were set with "close enough" precision.
#
# Within the clamped band, the raw value still drives relative warmth:
# a close-in temperate world sits at the warm end of the temperate band,
# a far-out one at the cool end. The fiction wins; physics sets the gradient.
lum = body_def.get("star", {}).get("luminosity_solar",
STAR_LUMINOSITY.get(star_type, 1.0))
t_raw = 278.5 * (lum ** 0.25) / math.sqrt(max(distance_au, 0.01))
greenhouse = {"none": 0, "thin": 8, "standard": 33, "thick": 80}
t_raw += greenhouse.get(atmo, 0)
temperature_clamped = False
temperature_raw_K = float(t_raw)
if planet_class in CLASS_T_BAND:
t_lo, t_hi = CLASS_T_BAND[planet_class]
t_base = float(np.clip(t_raw, t_lo, t_hi))
if t_raw < t_lo or t_raw > t_hi:
temperature_clamped = True
log.debug(f" T_raw={t_raw:.0f}K clamped to [{t_lo},{t_hi}] "
f"for {planet_class} ({body_def.get('id','')})")
else:
t_base = t_raw
tilt_factor = 1.0 - (axial_tilt / 90.0) * 0.5
# Atmosphere controls heat redistribution — thicker atmo = smaller
# equator-pole gradient. Thin/no atmo = extreme day/night but we
# still want the planet class to read correctly at the poles.
atmo_gradient_scale = {"none": 0.6, "thin": 0.7, "standard": 1.0, "thick": 1.2}
lat_gradient = 60.0 * tilt_factor * atmo_gradient_scale.get(atmo, 1.0)
t_lat = t_base - lat_gradient * np.abs(lat_frac)
max_relief_km = body_def.get("terrain", {}).get("max_elevation_km", 10.0)
elev_land = np.where(elevation >= sea_level,
(elevation - sea_level) / (1.0 - sea_level + 1e-9), 0.0)
elev_km = elev_land * max_relief_km
lapse = 6.5 if atmo != "none" else 2.0
t_final = t_lat - lapse * elev_km
class_offset = {"frozen": -30, "volcanic": 20, "arid": 10}
t_final += class_offset.get(planet_class, 0)
geo_boost = {"low": 0, "moderate": 5, "high": 15, "extreme": 35}
t_final += geo_boost.get(geothermal, 0)
# Soft floor: prevent planet class from being contradicted at the poles.
# An arid world shouldn't have ice caps; a volcanic world shouldn't freeze.
# Clamp the minimum temperature to the class band's lower bound.
if planet_class in CLASS_T_BAND:
t_floor = CLASS_T_BAND[planet_class][0]
t_final = np.maximum(t_final, t_floor)
# Return absolute Kelvin grid plus audit metadata.
# Biome lookup needs absolute values; renderer normalises for display.
return t_final.astype(np.float32), temperature_clamped, temperature_raw_K
# ---------------------------------------------------------------------------
# 3. Moisture
# ---------------------------------------------------------------------------
def compute_moisture(body_def, elevation, sea_level, temperature,
lat_frac, lon_frac):
# Normalise temperature locally for moisture computation
t_norm = np.clip((temperature - temperature.min()) /
(temperature.max() - temperature.min() + 1e-9), 0, 1)
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
if atmo == "none":
return np.zeros((GRID_H, GRID_W), dtype=np.float32)
lat_abs = np.abs(lat_frac)
# Hadley cell bands
itcz = np.clip(1.0 - (lat_abs / 0.33), 0, 1)
subtr = np.clip(1.0 - np.abs(lat_abs - 0.50) / 0.17, 0, 1)
polar = np.clip((lat_abs - 0.67) / 0.33, 0, 1)
hadley = np.clip(itcz * 0.85 + subtr * 0.10 + polar * 0.40, 0, 1)
# Ocean proximity
surf_water = elevation < sea_level
if surf_water.any():
from scipy.ndimage import distance_transform_edt
dist = distance_transform_edt(~surf_water).astype(np.float32)
ocean_prox = 1.0 - np.clip(dist / (dist.max() * 0.5 + 1e-9), 0, 1)
else:
ocean_prox = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# Rain shadow — westerly winds: windward (west face) is wet
shift = max(1, GRID_W // 80)
elev_above = np.clip(elevation - sea_level, 0, None)
elev_sh = np.clip(np.roll(elevation, shift, axis=1) - sea_level, 0, None)
shadow_raw = np.clip(elev_sh - elev_above * 0.5, 0, None)
shadow_raw = shadow_raw / (shadow_raw.max() + 1e-9)
rain_shadow = 1.0 - shadow_raw * 0.70
moisture = (hadley * 0.40
+ ocean_prox * 0.45
+ t_norm * 0.15) * rain_shadow
class_scale = {
"arid": 0.25, "oceanic": 1.30, "forest": 1.30,
"frozen": 0.55, "volcanic": 0.40, "barren": 0.05,
}
moisture *= class_scale.get(planet_class, 1.0)
hydro_scale = {
"ocean": 1.2, "liquid_water": 1.2,
"subsurface": 0.1, "none": 0.05,
}
moisture *= hydro_scale.get(hydro, 1.0)
moisture = gaussian_filter(moisture.astype(np.float32), sigma=2.0 * (GRID_W / 512.0))
# Only normalize if the raw range is substantial — otherwise the
# normalization re-inflates near-zero moisture on dry worlds back to [0,1].
m_min, m_max = moisture.min(), moisture.max()
if m_max > 0.05:
moisture = ((moisture - m_min) / (m_max - m_min + 1e-9)).astype(np.float32)
else:
# Effectively dry — clamp to near-zero
moisture = np.clip(moisture / 0.05, 0, 1).astype(np.float32)
return moisture
# ---------------------------------------------------------------------------
# 4. Hillshade
# ---------------------------------------------------------------------------
def compute_hillshade(elevation,
sun_azimuth_deg=315.0,
sun_altitude_deg=45.0):
scale = GRID_W / 8.0
gy, gx = np.gradient(elevation * scale)
mag = np.sqrt(gx**2 + gy**2 + 1.0)
nx = -gx / mag; ny = -gy / mag; nz = 1.0 / mag
az = math.radians(sun_azimuth_deg)
alt = math.radians(sun_altitude_deg)
lx = math.cos(alt) * math.cos(az)
ly = math.cos(alt) * math.sin(az)
lz = math.sin(alt)
diffuse = np.clip(nx * lx + ny * ly + nz * lz, 0.0, 1.0)
return (0.25 + 0.75 * diffuse).astype(np.float32)
# ---------------------------------------------------------------------------
# Rivers are NOT computed here (D-208, #963): river networks are derived by the
# Rust cascade's D8 drainage from the heightmap — the single source of river
# truth, with mouths that reach the sea by construction. The old heuristic
# `compute_rivers` was removed to avoid implying the Python sim owns rivers.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 6. Biome
# ---------------------------------------------------------------------------
# WHITTAKER_TABLE, EXOTIC_CLASSES loaded from biomes.toml via biome_config
def compute_biome(body_def, elevation, sea_level, surface_water,
temperature, moisture):
H, W = elevation.shape
biome = np.zeros((H, W), dtype=np.int8)
land = ~surface_water
atmo = body_def["physical"]["atmosphere"]
# --- Atmosphere gate ---
# Worlds with no or thin atmosphere can't support vegetation.
# Skip the Whittaker table entirely — classify by elevation and
# temperature only, using rock/dust/ice classes.
if atmo in ("none", "thin"):
# Dry terrain classes: 27=dust plain, 28=rocky highland,
# 29=warm dust, 30=cold rock. No vegetation possible.
# No ice on airless worlds — cold rock stays rock.
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
has_ice_source = hydro not in ("none", "subsurface") or atmo == "thin"
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
cf = np.full(land.sum(), 28, dtype=np.int8) # default: rocky highland
tf = temperature[land].ravel()
en = elev_norm[land].ravel()
# Moon vs planet: moons use grey lunar palette, planets use warm rock
is_lunar = body_def.get("body_type") == "moon"
if is_lunar:
# Lunar classes: 31=highland, 32=mare (dark basin), 33=midland
cf[:] = 33 # default: midland grey
cf[en > 0.50] = 31 # highland
cf[en < 0.20] = 32 # mare (dark basin floor)
if has_ice_source:
cf[tf < 200] = 17 # ice (only if water source)
else:
# Temperature-based classification using dry terrain classes
if has_ice_source:
cf[tf < 200] = 17 # ice/snow (only if water source)
else:
cf[tf < 200] = 30 # cold rock (no water = no ice)
cf[(tf >= 200) & (tf < 260)] = 30 # cold rock
cf[(tf >= 260) & (tf < 310)] = 28 # rocky highland
cf[(tf >= 310) & (tf < 340)] = 29 # warm dust
cf[tf >= 340] = 15 # hot desert (scorched)
# Elevation variation
if has_ice_source:
cf[(en > 0.70) & (tf < 273)] = 17 # high + cold = ice cap
cf[(en < 0.20) & (tf >= 260)] = 27 # low elevation = dust plain
biome[land] = cf
else:
# --- Standard Whittaker lookup for breathable/toxic atmospheres ---
tf = temperature[land].ravel()
mf = moisture[land].ravel()
cf = np.full(tf.shape, 17, dtype=np.int8) # default: ice
# Temperature fed to biome is absolute Kelvin — compare directly
for (tlo, thi, mlo, mhi, cls) in WHITTAKER_TABLE:
mask = (tf >= tlo) & (tf <= thi) & (mf >= mlo) & (mf <= mhi)
cf[mask] = cls
biome[land] = cf
# Ocean depth bands
if surface_water.any():
depth = np.clip((sea_level - elevation) / (sea_level + 1e-9), 0, 1)
biome[surface_water & (depth < 0.15)] = 2
biome[surface_water & (depth >= 0.15) & (depth < 0.50)] = 1
biome[surface_water & (depth >= 0.50)] = 0
# Frozen ocean — override ocean biome with ice shelf (class 26).
# Distinct from land ice (17) — slightly different appearance,
# blue tint suggests ocean beneath.
# Add noise to the freeze threshold so the boundary isn't a straight
# latitude line — ice edges are irregular in reality.
seed = body_def["seed"]
u_grid, v_grid, _, _, _ = _make_grids()
ice_noise = _fbm(u_grid, v_grid, seed + 900, octaves=4,
gain=0.5, base_freq=3.0) * 2.0 - 1.0
freeze_threshold = 271.0 + ice_noise * 8.0 # ±8K variation
frozen_ocean = surface_water & (temperature < freeze_threshold)
biome[frozen_ocean] = 26
# Very cold override — only on worlds with atmosphere (ice needs deposition)
if atmo not in ("none",):
biome[(temperature < 243.0) & land] = 17 # below -30C → ice
# Elevation overrides — mountain rock and permanent snow.
# Only apply snow on worlds with atmosphere (ice needs deposition).
elev_norm = np.where(land,
(elevation - sea_level) / (1.0 - sea_level + 1e-9),
0.0)
hydro_here = body_def.get("environment", {}).get("hydrosphere", "none")
has_ice_deposition = atmo not in ("none",) and hydro_here not in ("none", "subsurface")
if has_ice_deposition:
biome[land & (elev_norm > 0.85)] = 17
biome[land & (elev_norm > 0.65) & (temperature < 0.35)] = 18
# ── Modifier stack ─────────────────────────────────────────────────────
env = body_def.get("environment", {})
geothermal = env.get("geothermal_flux", "low")
chemosyn = env.get("chemosynthetic", False)
uv_index = env.get("uv_index", "moderate")
substrate = env.get("substrate", "silicate")
atmo = body_def["physical"]["atmosphere"]
planet_class = body_def["planet_class"].replace("_ringed", "")
# Geothermal: volcanic worlds get lava/ash at high elevations
if geothermal in ("extreme", "high") and planet_class == "volcanic":
biome[land & (elev_norm > 0.75)] = EXOTIC_CLASSES["lava_field"]
biome[land & (elev_norm > 0.45) & (elev_norm <= 0.75)] = EXOTIC_CLASSES["ash_field"]
# Thermophilic fields near heat vents on any high-geothermal world
if geothermal in ("extreme", "high") and not chemosyn:
hot = (temperature > 303.0) & land & (elev_norm < 0.45)
biome[hot] = EXOTIC_CLASSES["thermophilic_field"]
# Chemosynthetic worlds (Europa-type): cold surface, geothermal warmth
if chemosyn:
geo_warm = (temperature > 263.0) & (temperature < 293.0) & land
biome[geo_warm] = EXOTIC_CLASSES["chemosynthetic_mat"]
# UV radiation: cryptobiotic crust on exposed terrain with thin/no atmo
if uv_index in ("extreme", "high") and atmo in ("none", "thin"):
exposed = (land & (elev_norm > 0.15) & (elev_norm < 0.65)
& (moisture < 0.30)
& (biome != 17) & (biome != 18) & (biome != 19))
biome[exposed] = EXOTIC_CLASSES["cryptobiotic_crust"]
# Sulfuric substrate: scrub on volcanic mid-elevations
if substrate == "sulfuric":
scrub = land & (elev_norm > 0.25) & (elev_norm < 0.65) & (temperature > 0.35)
biome[scrub & (biome == 18)] = EXOTIC_CLASSES["sulfuric_scrub"]
# ── Anomaly scatter ─────────────────────────────────────────────────
# Sparse micro-features that break biome uniformity and tell stories.
# A high-frequency noise field selects ~2-5% of cells for anomaly
# replacement. The anomaly type depends on the surrounding biome context.
if atmo not in ("none",):
seed = body_def["seed"]
u_grid, v_grid, _, _, _ = _make_grids()
scatter_noise = _fbm(u_grid, v_grid, seed + 800, octaves=3,
gain=0.6, base_freq=12.0)
# High threshold = sparse features (~3% of land)
scatter_mask = (scatter_noise > 0.72) & land
if scatter_mask.any():
b_local = biome[scatter_mask]
t_local = temperature[scatter_mask]
m_local = moisture[scatter_mask]
e_local = elev_norm[scatter_mask]
new_b = b_local.copy()
# Temperate/forest → volcanic vent (lava at high elevation)
veg_mask = np.isin(b_local, [5, 6, 7, 8, 9, 10, 11])
new_b[veg_mask & (e_local > 0.50)] = 19 # lava field
new_b[veg_mask & (e_local > 0.35) & (e_local <= 0.50)] = 25 # ash
# Desert/dry → oasis with vegetation ring (only if water exists)
hydro = body_def.get("environment", {}).get("hydrosphere", "none")
has_water = hydro not in ("none", "subsurface")
dry_mask = np.isin(b_local, [13, 14, 15, 27, 28, 29])
if has_water:
# Very rare lake in desert lowlands
new_b[dry_mask & (e_local < 0.10) & (m_local > 0.20)] = 2 # shallow water
# Vegetation around moisture (oasis fringe — works even without
# standing water, represents subsurface moisture reaching roots)
new_b[dry_mask & (m_local > 0.15) & (e_local >= 0.10)] = 7 # savanna
# Frozen → geothermal hotspot with pioneer vegetation
cold_mask = np.isin(b_local, [16, 17])
new_b[cold_mask & (t_local > 260)] = 12 # shrubland (hardy plants)
# Volcanic → cooling zone with pioneer life
lava_mask = np.isin(b_local, [19, 25])
new_b[lava_mask & (t_local < 310) & (m_local > 0.30)] = 24 # lithic pioneer
biome[scatter_mask] = new_b
# Vegetation ring around oasis lakes: dilate water cells from the
# scatter pass and assign graduated vegetation to the ring.
# water → coast vegetation → savanna/shrub → original biome
oasis_water = (biome == 2) & land # scattered lake cells on land
if oasis_water.any():
from scipy.ndimage import binary_dilation
ring1 = binary_dilation(oasis_water, iterations=2) & ~oasis_water & land
ring2 = binary_dilation(oasis_water, iterations=4) & ~oasis_water & ~ring1 & land
# Inner ring: lush vegetation (coast/lowland green)
biome[ring1] = 4 # lowland
# Outer ring: transitional (savanna/shrub)
biome[ring2] = 12 # shrubland
return biome
# ---------------------------------------------------------------------------
# Top-level simulate()
# ---------------------------------------------------------------------------
def simulate(body_def: dict) -> dict:
"""
Run the full simulation stack for one body.
Parameters
----------
body_def : dict — from body_definition_parser.parse_system()
Returns
-------
dict terrain dict consumed by planet_renderer.render_globe()
Empty dict for gas giants (renderer handles those procedurally).
"""
planet_class = body_def.get("planet_class", "barren").replace("_ringed", "")
if planet_class == "gas_giant":
return {}
u, v, lat_frac, lon_frac, lat_rad = _make_grids()
elevation, sea_level, surface_water = compute_elevation(
body_def, u, v, lat_frac)
temperature, temp_clamped, temp_raw_K = compute_temperature(
body_def, elevation, sea_level, lat_frac)
moisture = compute_moisture(
body_def, elevation, sea_level, temperature, lat_frac, lon_frac)
hillshade = compute_hillshade(elevation)
biome = compute_biome(
body_def, elevation, sea_level, surface_water, temperature, moisture)
# Normalise temperature to [0,1] for renderer display — biome already computed
t_min, t_max = temperature.min(), temperature.max()
temperature_norm = ((temperature - t_min) / (t_max - t_min + 1e-9)).astype(np.float32)
return {
"elevation": elevation,
"temperature": temperature_norm, # normalised [0,1] for renderer
"moisture": moisture,
"biome": biome,
"surface_water": surface_water,
"hillshade": hillshade,
"sea_level": sea_level,
"_grid_w": GRID_W,
"_grid_h": GRID_H,
# Audit trail
"temperature_clamped": temp_clamped,
"temperature_raw_K": round(temp_raw_K, 1),
"temperature_band_K": list(CLASS_T_BAND.get(
body_def.get("planet_class","").replace("_ringed",""), [None,None])),
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import sys
import json
import time
import os
from PIL import Image
if len(sys.argv) < 2:
print("Usage: python3 planet_simulation.py body_def.json [--save-grids]")
sys.exit(1)
with open(sys.argv[1]) as f:
bd = json.load(f)
save_grids = "--save-grids" in sys.argv
print(f"Simulating: {bd['id']} ({bd['planet_class']})")
t0 = time.time()
terrain = simulate(bd)
if not terrain:
print("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()}")
ids, counts = np.unique(terrain['biome'], return_counts=True)
print(f" biomes: {list(zip(ids.tolist(), counts.tolist()))}")
if save_grids:
out = f"/tmp/{bd['id']}_grids"
os.makedirs(out, exist_ok=True)
for name in ("elevation", "temperature", "moisture", "hillshade"):
arr = terrain[name]
Image.fromarray((arr * 255).astype("uint8"), "L").save(
f"{out}/{name}.png")
print(f"Grids saved → {out}/")