Files
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

392 lines
15 KiB
Python
Raw Permalink 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.
"""
Earth (GJ0d) terrain builder.
Data sources:
- Elevation: ETOPO 2022 60 arc-second (NOAA) — GeoTIFF
- Temperature: WorldClim v2.1 annual mean (10 arc-min) — GeoTIFF
- Precipitation: WorldClim v2.1 annual total (10 arc-min) — GeoTIFF
- Rivers: Natural Earth 10m rivers — GeoJSON
All sources are equirectangular with col 0 = 180°W. ETOPO and WorldClim
use col 0 = 180°W natively. Natural Earth uses -180 to 180 longitude.
"""
import json
import zipfile
import numpy as np
from pathlib import Path
from sol_data.download import ensure_cached
from sol_data.shared import (
GRID_W, GRID_H,
load_tiff_as_array,
resample_to_grid, normalize_01, compute_sea_level,
compute_hillshade, assemble_terrain,
)
# ─── Data source URLs ───────────────────────────────────────────────────────
# ETOPO 2022 60 arc-second — surface elevation (ice surface, not bedrock)
# ~130 MB GeoTIFF, 21600 x 10800, int16 metres
ETOPO_URL = "https://www.ngdc.noaa.gov/mgg/global/relief/ETOPO2022/data/60s/60s_surface_elev_gtif/ETOPO_2022_v1_60s_N90W180_surface.tif"
ETOPO_FILE = "ETOPO_2022_v1_60s_N90W180_surface.tif"
# WorldClim v2.1 — 10 arc-minute resolution (migrated to geodata.ucdavis.edu)
# Temperature: mean annual, °C × 10 (int16), in a zip
WCLIM_TEMP_URL = "https://geodata.ucdavis.edu/climate/worldclim/2_1/base/wc2.1_10m_tavg.zip"
WCLIM_TEMP_FILE = "wc2.1_10m_tavg.zip"
# Precipitation: annual total mm (int16), in a zip
WCLIM_PREC_URL = "https://geodata.ucdavis.edu/climate/worldclim/2_1/base/wc2.1_10m_prec.zip"
WCLIM_PREC_FILE = "wc2.1_10m_prec.zip"
# Natural Earth 10m rivers — GeoJSON from GitHub
RIVERS_URL = "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_10m_rivers_lake_centerlines.geojson"
RIVERS_FILE = "ne_10m_rivers_lake_centerlines.geojson"
# Earth physical constants
EARTH_OCEAN_FRACTION = 0.71
EARTH_MIN_ELEV_M = -10994.0 # Mariana Trench
EARTH_MAX_ELEV_M = 8849.0 # Everest
# ─── River filtering ────────────────────────────────────────────────────────
# Rivers to include (smart scatter: 1-2 per continent + Rhine)
INCLUDED_RIVERS = {
# Europe
"Danube", "Volga", "Rhine",
# North America
"Mississippi", "St. Lawrence",
# South America
"Amazon", "Paraná",
# Africa
"Nile", "Congo",
# West Asia
"Tigris",
# East/South Asia
"Yangtze", "Ganges", "Mekong",
# Australia
"Murray",
}
# Fuzzy matching — some NE names differ slightly
RIVER_NAME_ALIASES = {
"Parana": "Paraná",
"Chang Jiang": "Yangtze",
"Huang He": "Yellow",
"Ganga": "Ganges",
"Nil": "Nile",
"Danau": "Danube",
"Donau": "Danube",
"Rhin": "Rhine",
"Rhein": "Rhine",
"Saint Lawrence": "St. Lawrence",
"St Lawrence": "St. Lawrence",
"Río Paraná": "Paraná",
"Rio Parana": "Paraná",
}
def _match_river_name(feature_name: str) -> str:
"""Check if a Natural Earth river name matches our included set."""
if not feature_name:
return None
name = feature_name.strip()
# Direct match
if name in INCLUDED_RIVERS:
return name
# Alias match
if name in RIVER_NAME_ALIASES:
alias = RIVER_NAME_ALIASES[name]
if alias in INCLUDED_RIVERS:
return alias
# Substring match (e.g. "Mississippi River" contains "Mississippi")
for included in INCLUDED_RIVERS:
if included.lower() in name.lower() or name.lower() in included.lower():
return included
return None
# ─── Data loaders ───────────────────────────────────────────────────────────
def _load_etopo() -> np.ndarray:
"""Load ETOPO 2022 elevation data, return raw metres array."""
path = ensure_cached(ETOPO_URL, ETOPO_FILE)
print(f" loading ETOPO: {path}")
try:
arr = load_tiff_as_array(str(path))
except Exception as e:
raise RuntimeError(
f"Failed to load ETOPO GeoTIFF: {e}\n"
f"If PIL can't read this TIFF, install Pillow with TIFF support "
f"or convert to raw binary."
) from e
print(f" ETOPO shape: {arr.shape}, range: [{arr.min():.0f}, {arr.max():.0f}] m")
return arr
def _load_worldclim_temperature() -> np.ndarray:
"""
Load WorldClim v2.1 annual mean temperature.
Returns temperature in Kelvin at native resolution.
"""
zip_path = ensure_cached(WCLIM_TEMP_URL, WCLIM_TEMP_FILE)
print(f" loading WorldClim temperature: {zip_path}")
# The zip contains monthly TIFFs (tavg_01.tif to tavg_12.tif).
# Compute annual mean from all 12 months.
cache_dir = zip_path.parent
monthly_sum = None
count = 0
with zipfile.ZipFile(zip_path) as zf:
tif_names = sorted([n for n in zf.namelist() if n.endswith(".tif")])
for tif_name in tif_names:
extracted = cache_dir / Path(tif_name).name
if not extracted.exists():
zf.extract(tif_name, cache_dir)
# Handle nested paths in zip
nested = cache_dir / tif_name
if nested != extracted and nested.exists():
nested.rename(extracted)
try:
arr = load_tiff_as_array(str(extracted))
except Exception:
# Try the nested path
nested = cache_dir / tif_name
if nested.exists():
arr = load_tiff_as_array(str(nested))
else:
continue
# Replace nodata with NaN
arr[arr < -999] = np.nan
if monthly_sum is None:
monthly_sum = arr.copy()
else:
monthly_sum += arr
count += 1
if count == 0:
raise RuntimeError("No temperature TIFFs found in WorldClim archive")
# Annual mean (WorldClim tavg is °C × 10)
temp_C = (monthly_sum / count) / 10.0
# Convert to Kelvin
temp_K = temp_C + 273.15
# Replace NaN (ocean/nodata) with a reasonable ocean temperature
temp_K = np.nan_to_num(temp_K, nan=288.0)
print(f" WorldClim temp shape: {temp_K.shape}, "
f"range: [{np.nanmin(temp_K):.0f}, {np.nanmax(temp_K):.0f}] K")
return temp_K
def _load_worldclim_precipitation() -> np.ndarray:
"""
Load WorldClim v2.1 annual precipitation (sum of 12 months).
Returns precipitation in mm/year at native resolution.
"""
zip_path = ensure_cached(WCLIM_PREC_URL, WCLIM_PREC_FILE)
print(f" loading WorldClim precipitation: {zip_path}")
cache_dir = zip_path.parent
annual_sum = None
with zipfile.ZipFile(zip_path) as zf:
tif_names = sorted([n for n in zf.namelist() if n.endswith(".tif")])
for tif_name in tif_names:
extracted = cache_dir / Path(tif_name).name
if not extracted.exists():
zf.extract(tif_name, cache_dir)
nested = cache_dir / tif_name
if nested != extracted and nested.exists():
nested.rename(extracted)
try:
arr = load_tiff_as_array(str(extracted))
except Exception:
nested = cache_dir / tif_name
if nested.exists():
arr = load_tiff_as_array(str(nested))
else:
continue
arr[arr < -999] = 0.0
if annual_sum is None:
annual_sum = arr.copy()
else:
annual_sum += arr
if annual_sum is None:
raise RuntimeError("No precipitation TIFFs found in WorldClim archive")
print(f" WorldClim precip shape: {annual_sum.shape}, "
f"range: [{annual_sum.min():.0f}, {annual_sum.max():.0f}] mm/yr")
return annual_sum
def _load_rivers_geojson() -> list:
"""
Load Natural Earth rivers GeoJSON and extract polylines for included rivers.
Returns list of (name, [(row, col), ...]) in grid coordinates.
"""
path = ensure_cached(RIVERS_URL, RIVERS_FILE)
print(f" loading rivers: {path}")
with open(path) as f:
geojson = json.load(f)
rivers = []
for feature in geojson.get("features", []):
props = feature.get("properties", {})
fname = props.get("name") or props.get("name_en") or ""
matched = _match_river_name(fname)
if not matched:
continue
geom = feature.get("geometry", {})
geom_type = geom.get("type", "")
coords_list = []
if geom_type == "LineString":
coords_list = [geom["coordinates"]]
elif geom_type == "MultiLineString":
coords_list = geom["coordinates"]
else:
continue
for coords in coords_list:
path_grid = []
for lon, lat in coords:
# Convert lon/lat to grid coordinates
# Grid: row 0 = 90°N, row 255 = 90°S
# col 0 = 180°W, col 511 = 180°E
row = int((90.0 - lat) / 180.0 * GRID_H)
col = int((lon + 180.0) / 360.0 * GRID_W)
row = max(0, min(GRID_H - 1, row))
col = max(0, min(GRID_W - 1, col))
# Deduplicate: skip if same grid cell as previous point.
# Natural Earth has hundreds of lon/lat points per river,
# many of which land on the same 512x256 cell. Without
# dedup, the renderer sees len(path)=300 and draws width 6.
if path_grid and path_grid[-1] == (row, col):
continue
path_grid.append((row, col))
if len(path_grid) >= 2:
rivers.append((matched, path_grid))
# Deduplicate: keep longest segment per river name
by_name = {}
for name, path in rivers:
if name not in by_name or len(path) > len(by_name[name]):
by_name[name] = path
print(f" matched {len(by_name)} rivers: {', '.join(sorted(by_name.keys()))}")
return [(name, path) for name, path in by_name.items()]
# ─── Main builder ───────────────────────────────────────────────────────────
def build_terrain(body_def: dict) -> dict:
"""
Build Earth terrain dict from real-world data.
Returns the same dict format as planet_simulation.simulate().
"""
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from planet_simulation import compute_biome
print(" Earth: loading real-world data...")
# ── 1. Elevation ────────────────────────────────────────────────────
etopo_raw = _load_etopo()
# ETOPO 2022 N90W180 is already col 0 = 180°W — no shift needed
# Resample to grid
elevation_m = resample_to_grid(etopo_raw, GRID_H, GRID_W, order=1)
# Normalise to [0, 1]
elevation = normalize_01(elevation_m, EARTH_MIN_ELEV_M, EARTH_MAX_ELEV_M)
# Sea level: Earth's ocean fraction is ~0.71
sea_level = compute_sea_level(elevation, EARTH_OCEAN_FRACTION)
surface_water = elevation < sea_level
print(f" elevation: sea_level={sea_level:.4f}, "
f"ocean={surface_water.sum()}/{GRID_H*GRID_W} cells")
# ── 2. Temperature ──────────────────────────────────────────────────
temp_raw_K = _load_worldclim_temperature()
# WorldClim uses col 0 = 180°W — no shift needed
temperature_K = resample_to_grid(temp_raw_K, GRID_H, GRID_W, order=1)
# Fill ocean areas with latitude-dependent ocean temperature
v = np.linspace(0, 1, GRID_H, dtype=np.float32)
lat_abs = np.abs(v - 0.5) * 2.0 # 0 at equator, 1 at poles
ocean_temp = 301.0 - lat_abs[:, np.newaxis] * 30.0 # ~28°C equator, ~-2°C poles
temperature_K = np.where(surface_water, ocean_temp, temperature_K)
print(f" temperature: [{temperature_K.min():.0f}, {temperature_K.max():.0f}] K")
# ── 3. Moisture ─────────────────────────────────────────────────────
precip_raw = _load_worldclim_precipitation()
# WorldClim uses col 0 = 180°W — no shift needed
precip = resample_to_grid(precip_raw, GRID_H, GRID_W, order=1)
# Normalise to [0, 1] — global max is ~10000 mm/yr (tropical rainforest)
moisture = normalize_01(precip, 0.0, 6000.0)
# Ocean moisture = high (drives adjacent land humidity)
moisture = np.where(surface_water, 0.9, moisture)
print(f" moisture: [{moisture.min():.2f}, {moisture.max():.2f}]")
# ── 4. Biome classification ─────────────────────────────────────────
# Use the existing Whittaker table with real temperature and moisture
biome = compute_biome(body_def, elevation, sea_level, surface_water,
temperature_K, moisture)
n_biomes = len(np.unique(biome))
print(f" biomes: {n_biomes} classes present")
# ── 5. Hillshade ────────────────────────────────────────────────────
hillshade = compute_hillshade(elevation)
# ── 6. Rivers ───────────────────────────────────────────────────────
named_rivers = _load_rivers_geojson()
# Clip rivers: stop each path when it hits surface water.
# Rivers like the Amazon/Nile/Rhine otherwise draw through seas.
clipped = []
for name, path in named_rivers:
clipped_path = []
for r, c in path:
if surface_water[r, c]:
break
clipped_path.append((r, c))
if len(clipped_path) >= 2:
clipped.append((name, clipped_path))
n_orig = len(named_rivers)
n_kept = len(clipped)
print(f" rivers: {n_kept}/{n_orig} kept after water clipping")
named_rivers = clipped
rivers = [path for _, path in named_rivers]
# ── 7. Assemble ─────────────────────────────────────────────────────
terrain = assemble_terrain(
elevation=elevation,
temperature_K=temperature_K,
moisture=moisture,
biome=biome,
surface_water=surface_water,
hillshade=hillshade,
rivers=rivers,
sea_level=sea_level,
)
# Store river names for the marker overlay
terrain["_river_names"] = {i: name for i, (name, _) in enumerate(named_rivers)}
return terrain