Ruff pre-push lint caught 17 unused imports across 7 files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
190 lines
7.8 KiB
Python
190 lines
7.8 KiB
Python
"""
|
||
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 sol_data.download import ensure_cached
|
||
from sol_data.shared import (
|
||
GRID_W, GRID_H,
|
||
load_raw_binary, resample_to_grid, normalize_01,
|
||
compute_hillshade, assemble_terrain,
|
||
temperature_grid_analytical,
|
||
)
|
||
|
||
# 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)
|
||
print(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
|
||
|
||
print(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."""
|
||
print(" 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 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)
|
||
|
||
print(f" 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)
|
||
|
||
print(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 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())
|
||
print(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())
|
||
print(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,
|
||
)
|