Ruff pre-push lint caught 17 unused imports across 7 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
175 lines
7.5 KiB
Python
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
|