Files
settled-reach/tooling/planet-gen/sol_data/titan.py
T
jpmschweitzerandClaude Opus 4.6 d364e1907d fix(assets): remove unused imports in sol pipeline
Ruff pre-push lint caught 17 unused imports across 7 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 22:25:28 +02:00

175 lines
7.5 KiB
Python

"""
Titan (GJ0g-1) terrain builder.
Titan is unique: dense nitrogen atmosphere, methane rain cycle,
methane/ethane lakes and rivers. Surface temperature ~94K uniform.
Data source:
- Surface: Cassini ISS global mosaic (4km resolution)
- Topography: very sparse Cassini radar altimetry (gap-filled)
Since Cassini topographic data is extremely sparse, we use the ISS
mosaic albedo to derive synthetic elevation (similar to ice moons)
with special handling for known methane lake regions.
Properties:
- planet_class: "frozen", atmosphere: "dense", hydrosphere: "rivers"
- Methane lakes primarily near the north pole (Kraken Mare, Ligeia Mare)
"""
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 (
GRID_W, GRID_H,
load_image_as_elevation, resample_to_grid, normalize_01,
compute_hillshade, assemble_terrain,
)
# Cassini ISS global mosaic
TITAN_MOSAIC_URL = "https://astrogeology.usgs.gov/cache/images/5e5ba96a58d3b38ee6e7b1e94b8c44e6_titan_iss_p19658_mosaic_global_4km.jpg"
TITAN_MOSAIC_FILE = "titan_cassini_iss_mosaic.jpg"
TITAN_SURFACE_TEMP_K = 94.0 # nearly uniform
TITAN_METHANE_LAKE_FRACTION = 0.02 # ~2% of surface is liquid methane
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}")
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")
albedo = _synthetic_titan_terrain()
# Dark regions = low (lakes/flat), bright = dunes/highlands
elevation = gaussian_filter(albedo, sigma=3.0)
return normalize_01(elevation)
def _synthetic_titan_terrain() -> np.ndarray:
"""Generate synthetic Titan terrain."""
rng = np.random.default_rng(94)
base = rng.random((GRID_H, GRID_W)).astype(np.float32)
base = gaussian_filter(base, sigma=6.0)
# Titan has equatorial dune fields (higher terrain)
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
equatorial = np.exp(-((v - 0.5) ** 2) / 0.02)
base += equatorial[:, np.newaxis] * 0.3
return normalize_01(base)
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
print(" Titan: loading data...")
# ── 1. Elevation ────────────────────────────────────────────────────
elevation = _load_titan_mosaic()
# 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
sea_level = compute_sea_level(elevation, TITAN_METHANE_LAKE_FRACTION)
surface_water = elevation < sea_level
# Concentrate lakes near north pole (real Titan has lakes mostly 60-90°N)
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
lat_abs = np.abs(v - 0.5) * 2.0 # 0=equator, 1=poles
north_mask = v < 0.2 # north polar region (top 20% of grid = 72-90°N)
# Allow lakes only in polar regions — mask out equatorial/southern "seas"
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")
# ── 2. Temperature ──────────────────────────────────────────────────
# Titan has nearly uniform surface temp due to dense atmosphere + distance
temperature_K = np.full((GRID_H, GRID_W), TITAN_SURFACE_TEMP_K, dtype=np.float32)
# Very slight pole-equator gradient (~2K)
lat_temp = lat_abs[:, np.newaxis] * 2.0
temperature_K -= lat_temp
# ── 3. Moisture ─────────────────────────────────────────────────────
# Titan has a methane humidity cycle — higher moisture near poles
moisture = np.zeros((GRID_H, GRID_W), dtype=np.float32)
# Polar moisture (methane humidity)
polar_humid = np.clip(lat_abs[:, np.newaxis] - 0.5, 0, 1) * 0.6
moisture += polar_humid
# Some equatorial humidity (methane drizzle)
equatorial_humid = np.exp(-((v[:, np.newaxis] - 0.5) ** 2) / 0.05) * 0.2
moisture += equatorial_humid
# ── 4. Biome ────────────────────────────────────────────────────────
# Titan at 94K with dense atmosphere goes through Whittaker table
# Everything will classify as ice/snow (class 17) — which is correct
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
# 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")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
# ── 6. Rivers ───────────────────────────────────────────────────────
# Titan has methane drainage channels — add synthetic ones near poles
rivers = _titan_rivers(elevation, surface_water)
return assemble_terrain(
elevation=elevation, temperature_K=temperature_K,
moisture=moisture, biome=biome,
surface_water=surface_water, hillshade=hillshade,
rivers=rivers, sea_level=sea_level,
)
def _titan_rivers(elevation: np.ndarray, surface_water: np.ndarray) -> list:
"""
Generate synthetic methane drainage channels for Titan.
Simple downhill tracing from high-latitude sources to lakes.
"""
rivers = []
rng = np.random.default_rng(94)
# Start from a few points in the north polar region
for _ in range(5):
r = int(rng.integers(10, 50)) # north polar zone
c = int(rng.integers(0, GRID_W))
path = [(r, c)]
visited = {(r, c)}
for _ in range(200):
if surface_water[r, c]:
break
best_r, best_c = r, c
best_elev = elevation[r, c]
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1),
(-1, -1), (-1, 1), (1, -1), (1, 1)]:
nr, nc = r + dr, (c + dc) % GRID_W
if 0 <= nr < GRID_H and (nr, nc) not in visited:
if elevation[nr, nc] < best_elev:
best_elev = elevation[nr, nc]
best_r, best_c = nr, nc
if (best_r, best_c) == (r, c):
break
r, c = int(best_r), int(best_c)
path.append((r, c))
visited.add((r, c))
if len(path) >= 5:
rivers.append(path)
return rivers