aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/refine.py
diff options
context:
space:
mode:
Diffstat (limited to 'refine.py')
-rw-r--r--refine.py1273
1 files changed, 1273 insertions, 0 deletions
diff --git a/refine.py b/refine.py
new file mode 100644
index 0000000..4c0da9b
--- /dev/null
+++ b/refine.py
@@ -0,0 +1,1273 @@
+"""Regional refinement (spec docs/superpowers/specs/2026-09-25-regional-refinement-design.md): inside drawn regions,
+re-run erosion, rivers, lakes, ground and biomes on H3 cells two resolutions finer than the world, with the world
+build as the boundary. Generated terrain (`idea`). Outside mapgen/ so the world build cache is unaffected."""
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import math
+import os
+import shutil
+import tempfile
+import threading
+import time
+from pathlib import Path
+from types import SimpleNamespace
+
+import h3.api.basic_int as h3
+import numpy as np
+
+import h3par
+
+from mapgen import fields as FL, hydrology as HY, ice as IC
+from mapgen.config import params
+from mapgen.environment import DEFAULTS as ENV_DEFAULTS, ground, holdridge
+from mapgen.graph import (OCEAN_MIN_KM2, accumulate, components, drop_smooth_cache, priority_flood, receiver_levels,
+ smooth_km, steepest_receivers)
+from scipy.spatial import cKDTree
+
+from mapgen.grid import Grid
+from mapgen.sphere import latlon_to_xyz
+
+import seafloor as SF
+import servecache
+
+MODEL = 9 # bump when the refinement model changes (part of the cache key); 2: lake depth, shoulders;
+ # 3: halo outlets and walls; 4: 1 km hillslope (canyons stay narrow); 5: smooth bedrock
+ # means, connected sea, lake-aware incision, border report; 6: sea-floor pass (canyons,
+ # fans, ponds, volcanic fields, vents), zone fields from the world cells; 7: world lakes
+ # (deep, carved beds) fill to their surface and are ways out; no lake re-carving;
+ # 8: sea only where it reaches the open ocean (walled-off world ocean: lagoon lakes);
+ # 9: world lakes keep their world way out (no fine sill dams them above their level)
+FINER = 2 # fine resolution = world resolution + FINER (49 children per world cell)
+LAPSE_C_PER_KM = 6.5
+RELIEF_MIN_KM = 5.0 # procedural relief kept at wavelengths ≥ ≈ the fine cell spacing
+COPY = ("continental", "age_class", "plate", "lithology", "landform", "seasonality", "P_ann", "P_jun", "P_dec", "PET",
+ "dist_ocean_km", "m_o2_zones", "m_gravity_zones", "deposits", "deposit_main")
+LAPSE = ("T_mean", "T_min", "T_jun", "T_dec", "biotemp")
+
+
+
+def load_regions(path: Path) -> list[dict]:
+ path = Path(path)
+ return json.loads(path.read_text()).get("regions", []) if path.exists() else []
+
+
+def area_cells(regions: list[dict], fine_res: int) -> list[np.ndarray]:
+ """Cells of the union of all outlines, split into connected areas: outlines that overlap or touch merge (each
+ outline's own cells count as one piece). Arrays, not sets: a continent has millions of cells."""
+ pieces, caps = [], []
+ for r in regions:
+ c = np.unique(np.array(h3.polygon_to_cells(h3.LatLngPoly([tuple(p) for p in r["outline"]]), fine_res),
+ dtype=np.uint64))
+ if len(c):
+ pieces.append(c)
+ o = np.asarray(r["outline"], dtype=np.float64)
+ v = latlon_to_xyz(o[:, 0], o[:, 1]).reshape(-1, 3)
+ m = v.mean(axis=0)
+ m = m / max(np.linalg.norm(m), 1e-12)
+ caps.append((m, float(np.max(np.arccos(np.clip(v @ m, -1, 1)))))) # a cap holding the outline
+ parent = list(range(len(pieces)))
+
+ def find(k):
+ while parent[k] != k:
+ parent[k] = parent[parent[k]]
+ k = parent[k]
+ return k
+ order = sorted(range(len(pieces)), key=lambda k: len(pieces[k]))
+ for a_ in range(len(order)):
+ for b_ in range(a_ + 1, len(order)):
+ i_, j_ = order[a_], order[b_]
+ (ci, ri), (cj, rj) = caps[i_], caps[j_]
+ apart = math.acos(float(np.clip(ci @ cj, -1, 1))) > ri + rj + 0.01 # caps apart (+ ≈ 2 cells): no touch
+ if not apart and find(i_) != find(j_) and _touch(pieces[i_], pieces[j_]):
+ parent[find(i_)] = find(j_)
+ groups = {}
+ for k in range(len(pieces)):
+ groups.setdefault(find(k), []).append(pieces[k])
+ out = [np.unique(np.concatenate(g)) if len(g) > 1 else g[0] for g in groups.values()]
+ return sorted(out, key=lambda a: int(a[0]))
+
+
+def _touch(small: np.ndarray, big: np.ndarray) -> bool:
+ """Whether two cell sets overlap or are neighbours (the smaller one's cells and their rings against the other)."""
+ if np.isin(small, big, assume_unique=True).any():
+ return True
+ for c in small.tolist(): # only the edge of the smaller one can touch
+ ring = np.array(h3.grid_ring(c, 1), dtype=np.uint64)
+ k = np.searchsorted(big, ring)
+ k[k >= len(big)] = 0
+ if (big[k] == ring).any():
+ return True
+ return False
+
+
+def landmass(a, lat: float, lon: float) -> np.ndarray:
+ """Indices of the world's land cells connected to the land cell nearest (lat, lon) (a continent or island)."""
+ ids = np.asarray(a["g_ids"])
+ land = ~np.asarray(a["ocean"]).astype(bool)
+ L = np.where(land)[0]
+ start = L[cKDTree(np.asarray(a["g_xyz"])[L]).query(latlon_to_xyz(lat, lon).reshape(3))[1]]
+ seen, stack = {int(start)}, [int(start)]
+ while stack:
+ for n in h3.grid_ring(int(ids[stack.pop()]), 1):
+ j = int(np.searchsorted(ids, np.uint64(n)))
+ if j < len(ids) and ids[j] == n and land[j] and j not in seen:
+ seen.add(j)
+ stack.append(j)
+ return np.array(sorted(seen), dtype=np.int64)
+
+
+def land_outline(a, lat: float, lon: float, step_deg: float = 0.5, margin: int = 1, max_points: int = 500) -> list:
+ """A simple outline polygon [[lat, lon], …] around the landmass at (lat, lon): its cells on a step_deg grid,
+ grown by `margin` grid cells (the coast and a strip of sea), holes filled, traced along the grid lines."""
+ from scipy.ndimage import binary_dilation, binary_fill_holes, label
+ comp = landmass(a, lat, lon)
+ la = np.asarray(a["g_lat"])[comp]
+ lo = np.asarray(a["g_lon"])[comp]
+ c0 = float(np.degrees(np.arctan2(np.sin(np.radians(lo)).mean(), np.cos(np.radians(lo)).mean())))
+ rel = (lo - c0 + 180.0) % 360.0 - 180.0 # longitudes around the landmass's middle
+ while True:
+ r0, q0 = np.floor(la.min() / step_deg) - margin - 1, np.floor(rel.min() / step_deg) - margin - 1
+ R = int(np.ceil(la.max() / step_deg) - r0 + margin + 2)
+ Q = int(np.ceil(rel.max() / step_deg) - q0 + margin + 2)
+ m = np.zeros((R, Q), bool)
+ m[(np.floor(la / step_deg) - r0).astype(int), (np.floor(rel / step_deg) - q0).astype(int)] = True
+ m = binary_fill_holes(binary_dilation(m, np.ones((3, 3), bool), iterations=margin) if margin else m)
+ lab, _ = label(m) # the piece holding the landmass
+ m = lab == lab[int(np.floor(la[0] / step_deg) - r0), int(np.floor(rel[0] / step_deg) - q0)]
+ while True: # no cells touching at a corner only
+ d1 = m[:-1, :-1] & m[1:, 1:] & ~m[:-1, 1:] & ~m[1:, :-1]
+ d2 = m[:-1, 1:] & m[1:, :-1] & ~m[:-1, :-1] & ~m[1:, 1:]
+ if not (d1.any() or d2.any()):
+ break
+ m[:-1, 1:] |= d1
+ m[:-1, :-1] |= d2
+ m = binary_fill_holes(m)
+ nxt = {} # boundary edges, inside on the left
+ for r, q in zip(*np.nonzero(m)):
+ if r == 0 or not m[r - 1, q]:
+ nxt[(r, q + 1)] = (r, q)
+ if r == R - 1 or not m[r + 1, q]:
+ nxt[(r + 1, q)] = (r + 1, q + 1)
+ if q == 0 or not m[r, q - 1]:
+ nxt[(r, q)] = (r + 1, q)
+ if q == Q - 1 or not m[r, q + 1]:
+ nxt[(r + 1, q + 1)] = (r, q + 1)
+ start = min(nxt)
+ loop, v = [start], nxt[start]
+ while v != start:
+ loop.append(v)
+ v = nxt[v]
+ pts = [p for k, p in enumerate(loop) # corners only
+ if (loop[k - 1][0] - p[0], loop[k - 1][1] - p[1]) != (p[0] - loop[(k + 1) % len(loop)][0],
+ p[1] - loop[(k + 1) % len(loop)][1])]
+ if len(pts) <= max_points:
+ return [[round(float((r + r0) * step_deg), 6), round(float((q + q0) * step_deg + c0 + 180.0) % 360.0 - 180.0, 6)]
+ for r, q in pts]
+ step_deg *= 1.25
+
+
+def plateau_outline(p: dict, seed: int, radius_km: float, margin_km: float = 300.0, n: int = 96) -> list:
+ """An outline [[lat, lon], …] around a sunken plateau (mapgen.plateaus' noise-warped ellipse) grown by margin_km:
+ per bearing, the farthest point still inside the plateau plus the margin (star-shaped: a simple polygon)."""
+ from mapgen import plateaus as PL
+ from mapgen.sphere import east_north, great_circle_point, xyz_to_latlon
+ c = latlon_to_xyz(*p["center"])
+ e, nn = east_north(c[None])
+ a, _ = PL.semi_axes(p)
+ steps = np.linspace(0.0, a * (1.0 + PL.EDGE_WARP) / (1.0 - PL.EDGE_WARP), 400)
+ out = []
+ for az in np.linspace(0.0, 2.0 * np.pi, n, endpoint=False):
+ t = np.cos(az) * nn[0] + np.sin(az) * e[0]
+ pts = np.cos(steps / radius_km)[:, None] * c + np.sin(steps / radius_km)[:, None] * t
+ inside = np.flatnonzero(PL.rho(pts, p, seed, radius_km) < 1.0)
+ r = float(steps[inside[-1]]) if len(inside) else 0.0
+ la, lo = xyz_to_latlon(great_circle_point(c, t, r + margin_km, radius_km))
+ out.append([round(float(la), 4), round(float(lo), 4)])
+ return out
+
+
+def region_key(region: dict) -> str:
+ """A region's key in the build record: its id, or its outline for hand-written regions without one."""
+ return str(region.get("id") or "outline-" + outline_hash(region))
+
+
+def outline_hash(region: dict) -> str:
+ return hashlib.sha1(json.dumps(region["outline"]).encode()).hexdigest()[:12]
+
+
+def region_areas(regions: list[dict], areas: list[np.ndarray], fine_res: int) -> dict:
+ """{region id: {"outline": outline hash, "area": hash of the area holding it (None: too small for a cell)}}."""
+ out = {}
+ for r in regions:
+ cells = h3.polygon_to_cells(h3.LatLngPoly([tuple(p) for p in r["outline"]]), fine_res)
+ c = np.uint64(cells[0]) if cells else None
+ hit = None
+ for a in areas:
+ k = int(np.searchsorted(a, c)) if c is not None else len(a)
+ if k < len(a) and a[k] == c:
+ hit = area_hash(a)
+ break
+ out[region_key(r)] = {"outline": outline_hash(r), "area": hit}
+ return out
+
+
+def read_record(regions_root: Path, key: str):
+ """What the last successful build made of which outline (build_areas writes it), or None."""
+ try:
+ rec = json.loads((results_dir(regions_root, key) / "outlines.json").read_text())
+ except (OSError, ValueError):
+ return None
+ ok = isinstance(rec, dict) and isinstance(rec.get("regions"), dict) and all(
+ isinstance(v, dict) and "outline" in v and "area" in v for v in rec["regions"].values())
+ return rec if ok else None # a damaged record counts as none (a rebuild)
+
+
+def area_hash(cells: np.ndarray) -> str:
+ return hashlib.sha1(np.asarray(cells, dtype=np.uint64).tobytes()).hexdigest()[:12]
+
+
+AREAS = "areas" # <regions_root>/areas/<key>/: results by content, shared between worlds
+KEY_SKIP = ("era_mask",) # era bookkeeping (and zone_* weights), not terrain: equal ground, equal key
+KEY_RINGS = 3 # world cells around an area whose values its refinement reads (relief raster,
+ # mean correction, river crossings)
+
+
+def areas_dir(regions_root: Path) -> Path:
+ return Path(regions_root) / AREAS
+
+
+def world_dirs(root: Path, res: int, log=print) -> list:
+ """(era, world folder) of every world the viewer can show: the base build first (named after the first era
+ without events, else "base"), then each era with events that has been built (out/r<res>/eras/<era>/)."""
+ from mapgen import config as C, eras as ER
+ root = Path(root)
+ _, tect = C.load(root)
+ order = (tect.get("eras") or {}).get("order", [])
+ plain = [n for n in order if not C.era_events(tect, n)]
+ out = [(plain[0] if plain else "base", root / "out" / f"r{res}")]
+ for n in order:
+ if not C.era_events(tect, n):
+ continue
+ d = ER.era_dir(root, res, n)
+ if (d / "cells.npz").exists():
+ out.append((n, d))
+ else:
+ log(f"refine: era {n} is not built (run mapgen.py build): its areas wait")
+ return out
+
+
+def cell_parents(cells, res: int) -> np.ndarray:
+ """h3.cell_to_parent for an array of cells (all of resolution ≥ res), by the index bits: the resolution field
+ set to res, the digits below it set to 7 (unused)."""
+ cells = np.asarray(cells, dtype=np.uint64)
+ if len(cells) and int(((cells >> np.uint64(52)) & np.uint64(15)).min()) < res:
+ raise ValueError(f"cell_parents: a cell is coarser than resolution {res}")
+ unused = 0
+ for r in range(res + 1, 16):
+ unused |= 7 << ((15 - r) * 3)
+ out = (cells & np.uint64(~(15 << 52) & (2**64 - 1))) | np.uint64(res << 52)
+ return out | np.uint64(unused)
+
+
+def world_near(world: "WorldCells", cells, rings: int = KEY_RINGS) -> np.ndarray:
+ """Indices of the world cells under the area and `rings` rings around them."""
+ par = np.unique(cell_parents(cells, world.res))
+ near = np.unique(np.fromiter((n for c in par.tolist() for n in h3.grid_disk(c, rings)), dtype=np.uint64))
+ k = np.minimum(np.searchsorted(world.ids, near), len(world.ids) - 1)
+ return k[world.ids[k] == near].astype(np.int64)
+
+
+def area_key(world: "WorldCells", cells, cfg: dict, plateaus: list) -> str:
+ """Content key of an area's refinement: the model, its cells, the config and every world value it reads (the
+ world cells under and around it, their river levels, the plateaus there). An era that left those values as they
+ were gives the same key: the area is built once for both."""
+ import tiles
+ idx = world_near(world, cells)
+ h = hashlib.sha256(f"m{MODEL}:t{tiles.VERSION}:f{FINER}".encode())
+ h.update(np.asarray(cells, dtype=np.uint64).tobytes())
+ h.update(json.dumps(cfg, sort_keys=True, default=str).encode())
+ for k in sorted(world.a.keys()):
+ if k in KEY_SKIP or k.startswith("zone_"):
+ continue
+ v = world.a.peek(k)
+ if v.shape[:1] == world.ids.shape:
+ h.update(k.encode())
+ h.update(np.ascontiguousarray(v[idx]).tobytes())
+ h.update(np.ascontiguousarray(world.river_level[idx]).tobytes())
+ pid = np.asarray(world.a.peek("plateau_id"))[idx] if "plateau_id" in world.a else np.zeros(0, np.int64)
+ near = [int(i) for i in np.unique(pid) if 0 <= i < len(plateaus)]
+ h.update(json.dumps([plateaus[i] for i in near], sort_keys=True, default=str).encode())
+ return h.hexdigest()[:16]
+
+
+def _write_json(path: Path, obj) -> None:
+ fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".json")
+ with os.fdopen(fd, "w") as fh:
+ json.dump(obj, fh)
+ os.replace(tmp, path)
+
+
+def _remove(p: Path) -> None:
+ if p.is_symlink():
+ p.unlink(missing_ok=True)
+ elif p.is_dir():
+ shutil.rmtree(p, ignore_errors=True)
+
+
+def _link(set_dir: Path, name: str, target: Path) -> Path:
+ """set_dir/name → target as a relative symlink, replaced atomically (an old real folder goes first)."""
+ link = set_dir / name
+ rel = os.path.relpath(target, set_dir)
+ if link.is_symlink() and os.readlink(link) == rel:
+ return link
+ tmp = set_dir / f".{name}.link-{os.getpid()}"
+ tmp.unlink(missing_ok=True)
+ os.symlink(rel, tmp)
+ if link.exists() and not link.is_symlink():
+ shutil.rmtree(link)
+ os.replace(tmp, link)
+ return link
+
+
+def _rings(ids) -> tuple[np.ndarray, np.ndarray]:
+ """Neighbours of each cell (h3.grid_ring order), flat, with per-cell counts (pentagons have five)."""
+ return h3par.run(h3par.rings, ids)
+
+
+def subgrid(cells: np.ndarray, radius_km: float):
+ """The area cells plus a one-cell halo ring, as a Grid whose neighbours stay inside that set (numpy: a continent's
+ millions of cells must not become Python sets and lists)."""
+ cells = np.asarray(cells, dtype=np.uint64)
+ if len(cells) > 1 and not (cells[1:] > cells[:-1]).all():
+ cells = np.unique(cells)
+ nb_a, cnt_a = _rings(cells)
+ u = np.unique(nb_a)
+ p = np.minimum(np.searchsorted(cells, u), len(cells) - 1)
+ ring = u[cells[p] != u] # the halo: neighbours outside the area
+ del u, p
+ ids = np.sort(np.concatenate([cells, ring]), kind="stable") # sorted: the area and its ring
+ nb_r, cnt_r = _rings(ring) # the area's own rings are known: only the ring's
+ pc, pr = np.searchsorted(ids, cells), np.searchsorted(ids, ring)
+ order = np.empty(len(ids), np.int64) # each id's ring, in ids order
+ order[pc] = np.arange(len(cells))
+ order[pr] = len(cells) + np.arange(len(ring))
+ cnt = np.concatenate([cnt_a, cnt_r])
+ start = np.concatenate([[0], np.cumsum(cnt)[:-1]])
+ counts = cnt[order]
+ pool = np.concatenate([nb_a, nb_r])
+ del nb_a, nb_r
+ first = np.repeat(start[order] - np.concatenate([[0], np.cumsum(counts)[:-1]]), counts)
+ flat = pool[first + np.arange(int(counts.sum()))]
+ del pool, first
+ k = np.searchsorted(ids, flat)
+ k[k >= len(ids)] = 0
+ keep = ids[k] == flat # neighbours inside the set (in ring order)
+ per = np.add.reduceat(keep.astype(np.int64), np.concatenate([[0], np.cumsum(counts)[:-1]])) if len(ids) else counts
+ ptr = np.concatenate([[0], np.cumsum(per)]).astype(np.int64)
+ idx = k[keep].astype(np.int64)
+ del flat, k, keep
+ res = h3.get_resolution(int(ids[0]))
+ ll, area = h3par.run(h3par.centres_areas, ids)
+ g = Grid(res, radius_km, ids, ll[:, 0], ll[:, 1], latlon_to_xyz(ll[:, 0], ll[:, 1]), area * radius_km ** 2, ptr, idx)
+ halo = np.zeros(len(ids), bool)
+ halo[pr] = True
+ return g, halo
+
+
+class LazyArrays(dict):
+ """An npz file's arrays, each read on first use (a refinement needs a few of the world's fields, not all)."""
+ def __init__(self, path: Path):
+ super().__init__()
+ self._npz = np.load(path)
+ self._keys = set(self._npz.files)
+
+ def __missing__(self, k):
+ if k not in self._keys:
+ raise KeyError(k)
+ v = self[k] = self._npz[k]
+ return v
+
+ def __contains__(self, k):
+ return k in self._keys
+
+ def keys(self):
+ return self._keys
+
+ def __iter__(self):
+ return iter(self._keys)
+
+ def __len__(self):
+ return len(self._keys)
+
+ def peek(self, k):
+ """The array without keeping it (a key hashes every field once)."""
+ return dict.__getitem__(self, k) if dict.__contains__(self, k) else self._npz[k]
+
+
+class WorldCells:
+ def __init__(self, out_dir: Path):
+ self.a = LazyArrays(Path(out_dir) / "cells.npz")
+ self.meta = json.loads((Path(out_dir) / "cells_meta.json").read_text())
+ self.ids = self.a["g_ids"]
+ self.res = h3.get_resolution(int(self.ids[0]))
+ self._levels = None
+ self._tree = None
+
+ @property
+ def tree(self) -> cKDTree:
+ if self._tree is None:
+ self._tree = cKDTree(np.asarray(self.a["g_xyz"], dtype=np.float64))
+ return self._tree
+
+ @property
+ def river_level(self) -> np.ndarray:
+ """The world rivers' graded water levels (rivers.py, spec §10): how deep a river may cut where it leaves."""
+ if self._levels is None:
+ import rivers as RV
+ self._levels = RV.RiverNet(self.a, self.meta["legends"], float(self.meta["radius_km"])).level
+ return self._levels
+
+ def index(self, fine_ids) -> np.ndarray:
+ return np.searchsorted(self.ids, cell_parents(fine_ids, self.res))
+
+
+def initial_fields(g, halo, world: WorldCells, src) -> dict:
+ p = world.index(g.ids)
+ a = world.a
+ z = src.relief_at(g.lat, g.lon, RELIEF_MIN_KM)
+ dz_km = (z - a["z_surface_m"][p]) / 1000.0
+ out = {"parent": p, "elevation_m": z, "ocean_world": a["ocean"][p].astype(bool)}
+ for k in COPY:
+ if k in a:
+ out[k] = a[k][p]
+ for k in LAPSE:
+ out[k] = a[k][p] - LAPSE_C_PER_KM * dz_km
+ out["T_range"] = a["T_range"][p]
+ out["biotemp"] = np.clip(out["biotemp"], 0.0, 30.0)
+ return out
+
+
+EROSION = {"steps": 8, "dt_myr": 1.0, "k": 0.004, "m": 0.8, "hillslope_km": 1.0, "sediment_fill": 0.15,
+ "area_per_km3": 2000.0} # water flux → equivalent drainage area (0.5 m/yr runoff)
+K_MULT = {0: 0.0, 1: 1.5, 2: 1.2, 3: 1.0, 4: 1.0, 5: 0.6, 6: 0.9, 7: 0.8} # age class → erodibility (as the world)
+
+
+def k_mult(age_class) -> np.ndarray:
+ """K_MULT per cell (1.0 for classes it lacks)."""
+ c = np.asarray(age_class)
+ c = np.trunc(c).astype(np.int64) if c.dtype.kind == "f" else c.astype(np.int64)
+ table = np.array([K_MULT.get(i, 1.0) for i in range(max(K_MULT) + 1)], dtype=np.float64)
+ ok = (c >= 0) & (c < len(table))
+ return np.where(ok, table[np.where(ok, c, 0)], 1.0)
+
+
+def runoff_km3(g, f) -> np.ndarray:
+ p, pet = f["P_ann"], np.maximum(f["PET"], 1e-6)
+ aet = p / np.sqrt(1.0 + (p / pet) ** 2) # Pike (1964), as the world
+ return np.maximum(p - aet, 0.0) * g.area_km2 * 1e-6
+
+
+def crossings(g, halo, world):
+ """World river segments crossing the area outline: inflow (km³/yr) at the first inside cell, and the halo cells
+ where they leave, with the world river's level there."""
+ a = world.a
+ riv = np.where(a["river"].astype(bool))[0]
+ near = np.zeros(len(world.ids), bool) # only segments near the area can cross its outline
+ near[world_near(world, g.ids)] = True
+ riv = riv[near[riv] | near[np.asarray(a["recv"])[riv]]]
+ res = h3.get_resolution(int(g.ids[0]))
+ pos = {int(c): k for k, c in enumerate(g.ids)}
+ inflow, exits, levels = np.zeros(g.n), [], []
+ for i in riv:
+ j = int(a["recv"][i])
+ if j == i:
+ continue
+ pa, pb = a["g_xyz"][i], a["g_xyz"][j]
+ n = max(2, int(np.linalg.norm(pb - pa) * g.radius_km / 1.0)) # 1 km steps
+ t = np.linspace(0, 1, n)[:, None]
+ pts = pa * (1 - t) + pb * t
+ pts /= np.linalg.norm(pts, axis=1, keepdims=True)
+ lat, lon = np.degrees(np.arcsin(pts[:, 2])), np.degrees(np.arctan2(pts[:, 1], pts[:, 0]))
+ k = [pos.get(h3.latlng_to_cell(float(la), float(lo), res), -1) for la, lo in zip(lat, lon)]
+ inside = [kk >= 0 and not halo[kk] for kk in k]
+ for s in range(1, len(k)):
+ if not inside[s - 1] and inside[s]:
+ inflow[k[s]] += float(a["discharge_km3_yr"][i])
+ if inside[s - 1] and not inside[s] and k[s] >= 0:
+ exits.append(k[s])
+ lv = world.river_level
+ li = lv[i] if np.isfinite(lv[i]) else a["z_surface_m"][i]
+ lj = lv[j] if np.isfinite(lv[j]) else (0.0 if a["ocean"][j] else a["z_surface_m"][j])
+ levels.append(float(min(li, lj)))
+ return inflow, np.array(exits, dtype=np.int64), np.array(levels)
+
+
+WALL_M = 1.0e6 # halo cells that are no way out: walls for routing
+
+
+def outlets(g, halo, world, parent, z=None) -> np.ndarray:
+ """Halo cells where water may leave the area: where the world's own flow leaves it (a world cell with children
+ inside drains into this halo cell's world cell), or the world sea. Elsewhere (e.g. upstream of an inflowing
+ river) the halo is a wall, so inflows cross the area instead of turning back out."""
+ a = world.a
+ inside = np.unique(parent[~halo])
+ targets = np.setdiff1d(np.asarray(a["recv"])[inside], inside)
+ out = halo & (np.isin(parent, targets) | np.asarray(a["ocean"])[parent].astype(bool))
+ if z is not None: # and wherever the ground outside lies below the area cells next to it (water runs out there)
+ inner_nb = ~halo[g.dst]
+ low_in = np.full(g.n, np.inf)
+ np.minimum.at(low_in, g.src[inner_nb], np.asarray(z)[g.dst[inner_nb]])
+ out |= halo & (np.asarray(z) < low_in)
+ if not out.any(): # a closed basin in the world: its lowest border cell drains
+ k = np.where(halo)[0]
+ out[k[np.argmin(np.asarray(a["z_surface_m"])[parent[k]])]] = True
+ return out
+
+
+def erode_area(g, z, halo, sea, water, kmult, P=EROSION, outlet=None, progress=None):
+ """Stream-power incision (implicit, as the world build) driven by water flux, with the halo and the sea fixed;
+ water leaves through the sea and the outlet halo cells only (default: the whole halo)."""
+ z = np.asarray(z, dtype=np.float64).copy()
+ fixed = halo | sea
+ sinks = sea | (halo if outlet is None else outlet)
+ wall = halo & ~sinks
+ ar = np.arange(g.n)
+ for step in range(int(P["steps"])):
+ if progress:
+ progress("erosion", 0.1 + 0.5 * step / int(P["steps"]))
+ base = np.where(sea, 0.0, z) # rivers cut to sea level, never toward the seabed
+ zf = priority_flood(g, np.where(wall, WALL_M, base), sinks)
+ zb = np.where(fixed, base, z + P["sediment_fill"] * (zf - z))
+ recv, _, dist = steepest_receivers(g, zf)
+ recv = np.where(fixed, ar, recv)
+ levels = receiver_levels(recv)
+ q = accumulate(recv, levels, water)
+ F = P["k"] * kmult * P["dt_myr"] * (q * P["area_per_km3"]) ** P["m"] / np.where(np.isfinite(dist), dist, 1.0)
+ F[fixed] = 0.0
+ floor = zb.copy() # the level of the way out each cell drains to
+ for lv in levels[1:]:
+ floor[lv] = floor[recv[lv]]
+ zn = zb.copy()
+ for lv in levels[1:]:
+ r = recv[lv]
+ cut = np.minimum(zb[lv], (zb[lv] + F[lv] * zn[r]) / (1.0 + F[lv]))
+ zn[lv] = np.maximum(cut, np.minimum(floor[lv], zb[lv])) # never below it (hollows are no base level)
+ zn = smooth_km(g, zn, P["hillslope_km"], keep=True)
+ z = np.where(fixed, z, np.minimum(z, zn))
+ drop_smooth_cache(g)
+ return z
+
+
+def mean_correction(xyz, z, parent, halo, target, world, iters=12, hold=None, memo=None) -> np.ndarray:
+ """A smooth height correction (the compact 4-of-5 kernel over nearby world cell centres, evaluated at each fine
+ cell) that makes each world cell's area children average the target (the world's bedrock): no block steps
+ between world cells. Tapers to 0 over the two cell rows next to the halo (held at world values), so exits and
+ the edge meet the world without a step. Cells in `hold` (e.g. the fixed sea) count in the means but are not moved.
+ memo: a dict shared by calls on the same cells (the kernel and the taper are computed once)."""
+ z = np.asarray(z, dtype=np.float64)
+ inner = ~halo
+ wx = np.asarray(world.a["g_xyz"], dtype=np.float64)
+ if memo:
+ near, idx, w, has, taper = memo["geometry"]
+ else:
+ pids = np.unique(parent)
+ ring = world.tree.query(wx[pids], k=7)[1].ravel() # the parents and their world neighbours
+ near = np.unique(np.concatenate([pids, ring]))
+ nw = max(1, h3par.workers()) # KD-tree queries on threads: same answers
+ d, idx = cKDTree(wx[near]).query(np.asarray(xyz, dtype=np.float64), k=5, workers=nw)
+ w = np.clip(1.0 - d[:, :4] / np.maximum(d[:, 4:5], 1e-12), 0.0, None) ** 2
+ w = w / np.maximum(w.sum(1, keepdims=True), 1e-300)
+ idx = idx[:, :4]
+ has = np.bincount(parent[inner], minlength=len(wx)) > 0
+ xyz = np.asarray(xyz, dtype=np.float64)
+ step = np.median(cKDTree(xyz).query(xyz, k=2, workers=nw)[0][:, 1]) # the fine cell spacing
+ taper = (np.clip((cKDTree(xyz[halo]).query(xyz, workers=nw)[0] / step - 1.0) / 2.0, 0.0, 1.0) if halo.any()
+ else 1.0)
+ if memo is not None:
+ memo["geometry"] = (near, idx.astype(np.int32), w, has, taper)
+ corr = np.zeros(len(z))
+ for _ in range(iters):
+ zc = z + corr
+ sums = np.bincount(parent[inner], weights=zc[inner], minlength=len(wx))
+ cnt = np.bincount(parent[inner], minlength=len(wx))
+ resid = np.where(has, np.asarray(target, dtype=np.float64) - sums / np.maximum(cnt, 1), 0.0)
+ corr += taper * (resid[near][idx] * w).sum(1)
+ corr[halo] = 0.0
+ if hold is not None:
+ corr[hold] = 0.0
+ return corr
+
+
+RIVER_MIN_KM3_YR = 0.2
+LAKE_MIN_DEPTH_M = 15.0 # only hollows deeper than this hold lakes (5 km cells: shallow dips are noise)
+
+
+def _p_for_runoff(target_mm, pet):
+ """Precipitation (mm) whose Pike runoff is target_mm (bisection): lets the world hydrology code carry inflows."""
+ lo, hi = np.zeros_like(target_mm), target_mm + pet * 4 + 10
+ for _ in range(60):
+ mid = (lo + hi) / 2
+ run = mid - mid / np.sqrt(1 + (mid / pet) ** 2)
+ lo, hi = np.where(run < target_mm, mid, lo), np.where(run < target_mm, hi, mid)
+ return (lo + hi) / 2
+
+
+def _sea(g, z, ocean_world, halo, min_km2: float = OCEAN_MIN_KM2) -> np.ndarray:
+ """Sea: cells at or below 0 m under the world's ocean that reach it beyond the area (through the halo), or a
+ separate basin as big as the world counts as sea (its own rule). World ocean that the fine heights wall off is
+ no sea: a lagoon, which the lakes take (author 2026-10-03). Inland dry basins stay land."""
+ wet = np.asarray(z) <= 0
+ lab = components(g, wet)
+ ow = wet & np.asarray(ocean_world)
+ seas = np.unique(lab[ow & np.asarray(halo)])
+ area = np.bincount(lab[ow], weights=np.asarray(g.area_km2)[ow], minlength=int(lab.max()) + 1 if lab.size else 0)
+ seas = np.union1d(seas[seas >= 0], np.flatnonzero(area >= min_km2))
+ return wet & np.isin(lab, seas)
+
+
+OUTFLOW_TOL_M = 1.0 # a world lake may stand this much above its world level before its way out is cut
+OUTFLOW_DROP_M = 0.5 # the cut way out starts this far below the world lake's level
+
+
+def world_outflows(g, z, halo, sinks, parent, a) -> np.ndarray:
+ """z with each world lake's way out kept: a lake that the fine relief dams above its world level (a sill the world
+ cells' means hide) gets a channel cut along its world drainage path, down to where that path already drains lower
+ (the sea, a lower lake, a way out of the area), never below sea level. The channel follows the least digging (Dijkstra on the height above
+ the lake's level) and falls evenly from just below the lake's level. Lakes that drain low enough stay as they are."""
+ import heapq
+ z = np.asarray(z, dtype=np.float64).copy()
+ sinks = np.asarray(sinks, bool)
+ lake_w, ocean_w = np.asarray(a["lake"]).astype(bool), np.asarray(a["ocean"]).astype(bool)
+ lid, lev_w, recv = np.asarray(a["lake_id"]), np.asarray(a["lake_level_m"], np.float64), np.asarray(a["recv"])
+ inner_lake = ~halo & lake_w[parent]
+ ids = np.unique(lid[parent[inner_lake]])
+ ids = ids[ids >= 0]
+ if not ids.size:
+ return z
+ inside_w = np.zeros(len(lake_w), bool)
+ inside_w[parent[~halo]] = True
+ levels = {int(L): float(np.nanmax(lev_w[lid == L])) for L in ids}
+ zf = None
+ for L in sorted(levels, key=levels.get):
+ lvl = levels[L]
+ if not np.isfinite(lvl):
+ continue
+ own = lid == L
+ water = inner_lake & own[parent] & (z < lvl)
+ if not water.any():
+ continue
+ if zf is None:
+ zf = priority_flood(g, z, sinks)
+ if zf[water].min() <= lvl + OUTFLOW_TOL_M:
+ continue
+ exits = np.flatnonzero(own & ~own[recv]) # the world path: lake exit → sea or lower
+ path = set(exits.tolist())
+ for c in exits:
+ c = int(recv[c])
+ while c not in path and inside_w[c]:
+ path.add(c)
+ if ocean_w[c] or (lake_w[c] and lid[c] != L) or recv[c] == c:
+ break
+ c = int(recv[c])
+ corridor = np.isin(parent, list(path)) & (~halo | sinks)
+ seeds = np.flatnonzero(corridor & (zf <= lvl - OUTFLOW_DROP_M) & ~(own[parent] & (z < lvl)))
+ goal = corridor & own[parent] & (z < lvl)
+ if not seeds.size or not goal.any():
+ continue
+ cost = np.full(g.n, np.inf)
+ prev = np.full(g.n, -1, np.int64)
+ cost[seeds] = 0.0
+ heap = [(0.0, int(s)) for s in seeds]
+ heapq.heapify(heap)
+ hit = -1
+ while heap:
+ c0, i = heapq.heappop(heap)
+ if c0 > cost[i]:
+ continue
+ if goal[i]:
+ hit = i
+ break
+ for j in g.nbr_idx[g.nbr_ptr[i]:g.nbr_ptr[i + 1]]:
+ if not corridor[j]:
+ continue
+ cj = c0 + max(z[j] - lvl, 0.0) + 1e-3
+ if cj < cost[j]:
+ cost[j], prev[j] = cj, i
+ heapq.heappush(heap, (cj, int(j)))
+ if hit < 0:
+ continue
+ chain = [hit] # lake → … → a cell that drains lower
+ while prev[chain[-1]] >= 0:
+ chain.append(int(prev[chain[-1]]))
+ chain = np.array(chain[1:], dtype=np.int64)
+ if not chain.size:
+ continue
+ top = lvl - OUTFLOW_DROP_M
+ end = min(max(float(zf[chain[-1]]), 0.0), top) # the sea's surface, not its bed
+ z[chain] = np.minimum(z[chain], top + (end - top) * np.arange(1, len(chain) + 1) / len(chain))
+ zf = None
+ return z
+
+
+def _lake_water(g, z, parent, world, halo) -> tuple[np.ndarray, np.ndarray]:
+ """(water, surface): cells below a world lake's surface connected to its cells (as _sea for the world ocean), and
+ that lake's surface (m; NaN elsewhere). The world's sea is never lake water."""
+ a = world.a
+ sea = _sea(g, z, np.asarray(a["ocean"]).astype(bool)[parent], halo)
+ lake = np.asarray(a["lake"]).astype(bool)[parent]
+ lev = np.asarray(a["lake_level_m"] if "lake_level_m" in a else a["z_filled_m"], dtype=np.float64)[parent]
+ water, surf = np.zeros(g.n, bool), np.full(g.n, np.nan)
+ for L in np.unique(lev[lake]):
+ wet = (np.asarray(z) < L) & ~sea
+ lab = components(g, wet)
+ own = np.unique(lab[wet & lake & (lev == L)])
+ m = wet & np.isin(lab, own[own >= 0]) & ~water
+ water |= m
+ surf[m] = L
+ return water, surf
+
+
+ZONE_KEYS = ("gravity_g", "o2_fraction", "fire_reactivity", "plant_height_x")
+
+
+def zone_fields(world, parent, z_surface, sea_p, scale_height_m) -> dict:
+ """Air, gravity and fire of the fine cells: their world cell's values (config zones and era zone events alike);
+ the pressure falls from the world cell's sea-level pressure with the fine cell's own height."""
+ out = {k: np.asarray(world.a[k])[parent] for k in ZONE_KEYS}
+ out["pressure_bar"] = sea_p * FL.pressure(1.0, z_surface, scale_height_m)
+ out["po2_bar"] = np.asarray(out["o2_fraction"], dtype=np.float64) * out["pressure_bar"]
+ return out
+
+
+def refine_area(g, halo, world, src, cfg, progress=None, plateaus=None) -> dict:
+ tell = progress or (lambda stage, f: None)
+ tell("fields", 0.05)
+ f = initial_fields(g, halo, world, src)
+ p = f["parent"]
+ bedrock = np.asarray(world.a["elevation_eroded_m"], dtype=np.float64) # ice comes back on top later
+ mc = None if servecache.low_memory() else {} # the correction's kernel, built once
+ z0 = f["elevation_m"] + mean_correction(g.xyz, f["elevation_m"], p, halo, bedrock, world, memo=mc)
+ inflow, exits, exit_lev = crossings(g, halo, world)
+ lw, surf = _lake_water(g, z0, p, world, halo) # halo under a world lake stands at its surface
+ on_lake = halo & lw # (water fills it, not drains via its bed) and
+ z0 = np.where(on_lake, surf, z0) # is a way out (into the world lake)
+ np.minimum.at(z0, exits, exit_lev) # world exits are the lowest ways out
+ sea = _sea(g, z0, f["ocean_world"], halo)
+ water = runoff_km3(g, f) + inflow
+ kmult = k_mult(f["age_class"])
+ out_cells = outlets(g, halo, world, p, z0)
+ out_cells[exits] = True
+ out_cells |= on_lake
+ z0 = world_outflows(g, z0, halo, sea | out_cells, p, world.a) # no fine sill dams a world lake (MODEL 9)
+ z = erode_area(g, z0, halo, sea, water, kmult, outlet=out_cells, progress=tell)
+ z = z + mean_correction(g.xyz, z, p, halo, bedrock, world, hold=sea, memo=mc) # means back, smoothly
+ del mc
+ ocean = _sea(g, z, f["ocean_world"], halo) # land that sank to the sea joins it
+ tell("sea floor", 0.62) # canyons, fans, ponds, volcanoes, vents
+ z, sea_floor, vents = SF.run(g, z, ocean, halo, p, world.a, plateaus or [], int(cfg["build"]["seed"]))
+ out_cells |= outlets(g, halo, world, p, z) # the raised area may now spill over more halo
+ z = world_outflows(g, z, halo, ocean | out_cells, p, world.a) # again on the final heights (means put back)
+ # temperatures follow the final heights (lapse rate from the world's bedrock height, as the world climate)
+ dz_km = (z - bedrock[p]) / 1000.0
+ for k in LAPSE:
+ f[k] = np.asarray(world.a[k])[p] - LAPSE_C_PER_KM * dz_km
+ f["biotemp"] = np.clip(f["biotemp"], 0.0, 30.0)
+ tell("rivers", 0.65)
+ # the world hydrology code, on the sub-grid: outlets = the ways out; inflow carried as extra runoff
+ pet = np.maximum(f["PET"], 1e-6)
+ run_mm = np.where(ocean, 0.0, np.maximum(f["P_ann"] - f["P_ann"] / np.sqrt(1 + (f["P_ann"] / pet) ** 2), 0.0))
+ extra_mm = inflow / np.maximum(g.area_km2 * 1e-6, 1e-12)
+ p_hy = np.where(extra_mm > 0, _p_for_runoff(run_mm + extra_mm, pet), f["P_ann"])
+ p_hy = np.where(halo, 0.0, p_hy) # no rain from outside the area
+ hcfg = copy.deepcopy(cfg)
+ hcfg.setdefault("hydrology", {})["river_min_km3_yr"] = RIVER_MIN_KM3_YR
+ hcfg["hydrology"]["min_depth_m"] = LAKE_MIN_DEPTH_M
+ ctx = SimpleNamespace(grid=g, cfg=hcfg, seed=int(cfg["build"]["seed"]), data={
+ "elevation_eroded_m": np.where(halo & ~out_cells, WALL_M, z), "P_ann": p_hy, "PET": pet, "ocean": ocean | out_cells,
+ "lake_carve": np.zeros(g.n, bool)}) # lake beds come carved from the world
+ ctx.need = lambda *keys: [ctx.data[k] for k in keys]
+ hy = HY.run(ctx)
+ hy["runoff_mm"] = run_mm
+ river = hy["river"] & ~halo
+ tell("ground", 0.85)
+ # environment: life zones and ground from the refined fields (lithology, landform from the world cells)
+ P = params(cfg, "environment", ENV_DEFAULTS)
+ H = float(cfg["planet"]["scale_height_km"]) * 1000.0
+ sea_p = (np.asarray(world.a["pressure_bar"], dtype=np.float64)[p]
+ / FL.pressure(1.0, np.asarray(world.a["z_surface_m"])[p], H)) # the world cell's sea-level air
+ wet = np.sqrt(sea_p / float(cfg["planet"]["sea_level_pressure_bar"])) # dense air: rain acts wetter (eras)
+ zone, _ = holdridge(f["biotemp"], f["P_ann"] * wet, f["T_min"])
+ gr = ground(g, z, f["lithology"], f["T_mean"], f["T_min"], f["P_ann"], f["dist_ocean_km"], river, hy["strahler"],
+ hy["discharge_km3_yr"], hy["lake"] & ~halo, hy["salt_flat"] & ~halo, P, ocean)
+ ctx.data.update({"T_mean": f["T_mean"], "T_jun": f["T_jun"], "T_dec": f["T_dec"], "P_ann": f["P_ann"], "ocean": ocean,
+ "elevation_eroded_m": z}) # real heights again (no routing walls)
+ ic = IC.run(ctx)
+ fl = zone_fields(world, p, ic["z_surface_m"], sea_p, H)
+ exit_level = np.full(g.n, np.nan)
+ np.fmin.at(exit_level, exits, exit_lev)
+ out = {"g_ids": g.ids, "g_lat": g.lat, "g_lon": g.lon, "g_xyz": g.xyz, "g_area_km2": g.area_km2,
+ "parent": p, "halo": halo, "elevation_eroded_m": z, "ocean": ocean, "holdridge": zone, "ground": gr,
+ **{k: v for k, v in hy.items() if k not in ("river", "elevation_eroded_m", "lake_cut_m")}, "river": river, **ic, **fl,
+ **{k: f[k] for k in (*COPY, *LAPSE, "T_range")}}
+ out["lake"] = hy["lake"] & ~halo
+ out["inflow_km3_yr"] = inflow
+ out["outlet"] = out_cells
+ out["exit_level_m"] = exit_level
+ out["z_filled_m"] = np.where(halo & ~out_cells, z, out["z_filled_m"]) # no routing walls in the saved result
+ out.update(sea_floor)
+ out["vents"] = vents # per vent (build_areas: vents.npz)
+ return out
+
+
+def stats(a) -> dict:
+ """Build report: size, rivers, largest lakes and the water balance (sources vs outflow + lake evaporation)."""
+ inner = ~a["halo"]
+ q, recv = a["discharge_km3_yr"], a["recv"]
+ sink = a["ocean"] | a["outlet"]
+ out_q = q[~sink & sink[recv]].sum() # flow into the sea or out of the area
+ ar = np.arange(len(q))
+ terminal = q[~sink & (recv == ar)].sum() # endorheic basins keep (evaporate) theirs
+ lost = a.get("lake_loss_km3_yr", np.zeros(len(q)))[~sink].sum()
+ src = (a["runoff_mm"][inner & ~a["ocean"]] * a["g_area_km2"][inner & ~a["ocean"]] * 1e-6).sum() + a["inflow_km3_yr"].sum()
+ lakes = np.bincount(np.maximum(a["lake_id"][a["lake"]], 0), weights=a["g_area_km2"][a["lake"]]) if a["lake"].any() else np.zeros(1)
+ # border: where world rivers leave, the refined water level just inside vs the world river's level there
+ lev = a.get("exit_level_m", np.full(len(q), np.nan))
+ errs = [float(a["z_filled_m"][(recv == e) & inner].min() - lev[e]) for e in np.where(np.isfinite(lev))[0]
+ if ((recv == e) & inner).any()]
+ return {"cells": int(inner.sum()), "rivers": int(a["river"].sum()),
+ "largest_lakes_km2": sorted(lakes.tolist(), reverse=True)[:3],
+ "water_balance_error": float(abs(out_q + terminal + lost - src) / max(src, 1e-9)),
+ "exits": int(np.isfinite(lev).sum()), "exits_reached": len(errs),
+ "exit_level_error_m": max(errs, key=abs) if errs else None,
+ "canyon_max_m": float(a["canyon_m"][inner].max()) if "canyon_m" in a and inner.any() else 0.0,
+ "fan_max_m": float(a["fan_m"][inner].max()) if "fan_m" in a and inner.any() else 0.0}
+
+
+def results_dir(regions_root: Path, key: str) -> Path:
+ return Path(regions_root) / f"{key}-m{MODEL}"
+
+
+def built_areas(regions_root: Path, key: str) -> list[Path]:
+ d = results_dir(regions_root, key)
+ return sorted(p for p in d.glob("*") if (p / "cells.npz").exists()) if d.exists() else []
+
+
+def regions_fingerprint(regions_root: Path, key: str, dirs: list | None = None) -> str:
+ """Changes whenever any built result changes (model, cells, rebuild): tile URLs must follow. dirs: a listing
+ already made (built_areas)."""
+ h = hashlib.sha1(f"m{MODEL}".encode())
+ for p in (built_areas(regions_root, key) if dirs is None else dirs):
+ st = (p / "cells.npz").stat()
+ h.update(f"{p.name}:{st.st_size}:{st.st_mtime_ns}".encode())
+ return h.hexdigest()[:6]
+
+
+def _save(d: Path, arrays: dict, meta: dict, vents: dict | None = None) -> None:
+ d.mkdir(parents=True, exist_ok=True)
+ (d / "cells.npz").unlink(missing_ok=True) # cells.npz last: its presence means "complete"
+ fd, tmp = tempfile.mkstemp(dir=d, suffix=".json")
+ with os.fdopen(fd, "w") as fh:
+ fh.write(json.dumps(meta, indent=1) + "\n")
+ os.replace(tmp, d / "meta.json")
+ if vents is not None:
+ fd, tmp = tempfile.mkstemp(dir=d, suffix=".npz")
+ with os.fdopen(fd, "wb") as fh:
+ np.savez_compressed(fh, **vents)
+ os.replace(tmp, d / "vents.npz")
+ fd, tmp = tempfile.mkstemp(dir=d, suffix=".npz")
+ with os.fdopen(fd, "wb") as fh:
+ np.savez_compressed(fh, **arrays)
+ os.replace(tmp, d / "cells.npz")
+
+
+def _meta(d: Path):
+ try:
+ return json.loads((d / "meta.json").read_text())
+ except (OSError, ValueError):
+ return None
+
+
+def build_areas(root: Path, res: int, regions_path: Path, log=print, regions_root: Path | None = None,
+ progress=None) -> list[dict]:
+ """Refine every connected area of regions_path's outlines for every world the viewer can show (the base build
+ and each built era). Results are keyed by content (area_key) under <regions_root>/areas/<key>/, so an area an era
+ left as it was is built once; each world's set folder <regions_root>/<world fingerprint>-m<MODEL>/ links its areas
+ by cell hash (what RegionSet reads) and records its outlines (outlines.json) and era (era.json). Other worlds'
+ sets and unused areas go only after every area built (a failed build keeps the map as it was).
+ progress(stage, fraction) reports a running build (fraction over all areas still to build)."""
+ import gc
+
+ import serve
+ import tiles
+ from mapgen import config as C
+ root = Path(root)
+ cfg, tect = C.load(root)
+ plateaus = tect.get("plateau", [])
+ regions_root = Path(regions_root or (root / "out" / f"r{res}" / "regions"))
+ store = areas_dir(regions_root)
+ store.mkdir(parents=True, exist_ok=True)
+ regions = load_regions(regions_path) # once: the records must describe what was built
+ areas = area_cells(regions, res + FINER)
+ try:
+ memo = json.loads((store / "keys.json").read_text())
+ except (OSError, ValueError):
+ memo = {}
+ if progress:
+ progress("keys", 0.0)
+ sets = []
+ for era, out_dir in world_dirs(root, res, log):
+ world = serve.World(root, res, out=out_dir)
+ src = tiles.TileSource(world, int(cfg["build"]["seed"]), regions_dir=regions_root, with_regions=False)
+ wc = WorldCells(out_dir)
+ keys = {}
+ for cells in areas:
+ m = f"{src.fingerprint}:m{MODEL}:{area_hash(cells)}" # a new model: new keys
+ if m not in memo:
+ memo[m] = area_key(wc, cells, cfg, plateaus)
+ keys[area_hash(cells)] = memo[m]
+ sets.append((era, world, src, wc, keys))
+ todo, queued = [], set()
+ for era, world, src, wc, keys in sets:
+ for cells in areas:
+ k = keys[area_hash(cells)]
+ done = (store / k / "cells.npz").exists() and _meta(store / k) is not None
+ if not done and k not in queued:
+ queued.add(k)
+ todo.append((cells, k, era, world, src, wc))
+ for n, (cells, k, era, world, src, wc) in enumerate(todo):
+ tell = (lambda stage, f, n=n: progress(stage, (n + f) / len(todo))) if progress else None
+ R = float(world.cells_meta["radius_km"])
+ km2 = len(cells) * float(np.mean([h3.cell_area(int(c), unit="rads^2") for c in cells[:200]])) * R ** 2
+ if km2 > 2.0e6:
+ log(f"refine: area {area_hash(cells)} is {km2:,.0f} km² (over ≈ 2,000,000): this will take a while")
+ t0 = time.time()
+ g, halo = subgrid(cells, R)
+ a = refine_area(g, halo, wc, src, cfg, progress=tell, plateaus=plateaus)
+ vents = a.pop("vents", None)
+ meta = {"hash": area_hash(cells), "key": k, "model": MODEL, "era": era, "world": src.fingerprint,
+ "area_km2": km2, "vents": 0 if vents is None else int(len(vents["cell"])), "seconds": round(time.time() - t0, 1), **stats(a)}
+ if tell:
+ tell("saving", 0.95)
+ _save(store / k, a, meta, vents)
+ log(f"refine: area {meta['hash']} ({era}): {meta['cells']} cells, {meta['rivers']} river cells, "
+ f"{meta['seconds']} s")
+ del a, g, halo # the next area must not share memory with this one
+ gc.collect()
+ trim_memory()
+ metas, built_now, keep_sets = [], {t[1] for t in todo}, set()
+ for era, world, src, wc, keys in sets: # the records: links, outlines, era
+ sd = results_dir(regions_root, src.fingerprint)
+ sd.mkdir(parents=True, exist_ok=True)
+ keep_sets.add(sd.name)
+ for cells in areas:
+ h, k = area_hash(cells), keys[area_hash(cells)]
+ link = _link(sd, h, store / k)
+ metas.append({**_meta(store / k), "dir": str(link), "era": era, "built": k in built_now})
+ _write_json(sd / "outlines.json", {"regions": region_areas(regions, areas, res + FINER)})
+ _write_json(sd / "era.json", {"era": era})
+ fps = {s[2].fingerprint for s in sets}
+ _write_json(store / "keys.json", {m: v for m, v in memo.items()
+ if m.split(":", 1)[0] in fps and m.split(":")[1] == f"m{MODEL}"})
+ # only after every area built: other worlds' sets, links of redrawn or deleted regions, unused areas go
+ names = {area_hash(c) for c in areas}
+ for d in regions_root.iterdir():
+ if d.name != AREAS and d.name not in keep_sets and (d.is_dir() or d.is_symlink()):
+ _remove(d)
+ used = set()
+ for s in keep_sets:
+ for p in (regions_root / s).iterdir():
+ if p.name.startswith(servecache.PREFIX) or p.name.startswith(".") or not (p.is_dir() or p.is_symlink()):
+ continue # serve caches: the server prunes them
+ if p.name in names and p.is_symlink():
+ used.add(os.path.basename(os.readlink(p)))
+ else:
+ _remove(p)
+ for p in store.iterdir():
+ if p.is_dir() and p.name not in used:
+ _remove(p)
+ return metas
+
+
+NET_KEYS = ("g_xyz", "g_ids", "river", "recv", "ocean", "lake", "z_surface_m", "z_filled_m", "discharge_km3_yr",
+ "lithology", "age_class", "landform", "ground", "P_ann") # what rivers.RiverNet reads
+BLEND_KM = 10.0
+LAKE_FLOOR_M = 30.0 # lake water reaches this far below a lake's deepest cell (procedural detail)
+
+
+def shoulders(a, radius_km: float = 12742.0) -> np.ndarray:
+ """Heights with each river cell raised to its valley shoulders (the mean of its dry neighbours), so smooth
+ interpolation keeps the plateau and the valley shape can be cut in at sub-cell scale."""
+ z = np.asarray(a["z_surface_m"], dtype=np.float64).copy()
+ riv = np.asarray(a["river"]).astype(bool) & ~np.asarray(a["halo"]).astype(bool)
+ dry = (~np.asarray(a["river"]).astype(bool) & ~np.asarray(a["lake"]).astype(bool) & ~np.asarray(a["ocean"]).astype(bool)
+ & ~np.asarray(a["halo"]).astype(bool))
+ if not riv.any() or not dry.any():
+ return z
+ tree = cKDTree(a["g_xyz"][dry])
+ spacing = np.sqrt(np.mean(a["g_area_km2"])) * 1.07 / radius_km # neighbour distance on the unit sphere
+ d, k = tree.query(a["g_xyz"][riv], k=min(6, int(dry.sum())), distance_upper_bound=1.5 * spacing)
+ d, k = np.atleast_2d(d), np.atleast_2d(k)
+ ok = np.isfinite(d)
+ zd = np.where(ok, z[dry][np.minimum(k, dry.sum() - 1)], 0.0)
+ mean = np.where(ok.any(1), zd.sum(1) / np.maximum(ok.sum(1), 1), -np.inf)
+ z[riv] = np.maximum(z[riv], mean)
+ return z
+
+
+def touches_trees(trees, spacing_km: float, radius_km: float, z, x, y, margin_km: float = 0.0) -> bool:
+ """Whether tile z/x/y comes within (2 cell spacings + margin) of the cells in any of the KD-trees."""
+ if not trees:
+ return False
+ span = 180.0 / 2 ** z
+ f = np.array([0.0, 0.5, 1.0])
+ LA, LO = np.meshgrid(90.0 - (y + f) * span, -180.0 + (x + f) * span, indexing="ij")
+ p = latlon_to_xyz(LA, LO).reshape(-1, 3)
+ r = float(np.max(np.linalg.norm(p - p[4], axis=1))) + (2 * spacing_km + margin_km) / radius_km
+ return any(t.query_ball_point(p[4], r, return_length=True) > 0 for t in trees)
+
+
+def trim_memory() -> None:
+ """Give freed memory back to the system (glibc keeps it otherwise)."""
+ try:
+ import ctypes
+ ctypes.CDLL("libc.so.6").malloc_trim(0)
+ except (OSError, AttributeError):
+ pass
+
+
+def _compute_region_set(dirs, legends: dict, radius_km: float, spill=None):
+ """Everything a RegionSet serves, computed from its areas' results (as the old RegionSet.__init__ did):
+ (arrays, meta) for the serve cache. Keys: 'a.<field>' (the concatenated inner cells, sorted by H3 id, recv
+ remapped), 'halo_xyz', 'area' (index into meta['area_names'] per fine cell), 'z_env', 'lake_level',
+ 'lake_floor', 'net.<state>' (rivers.STATE) when a river net exists. spill: a folder to keep the arrays in
+ (memory maps; same values) instead of RAM."""
+ R = float(radius_km)
+ keep = servecache.spiller(spill)
+ files = [np.load(p / "cells.npz") for p in dirs] # read key by key: each array once, no copies kept
+ try:
+ halo = [f["halo"].astype(bool) for f in files]
+ inner = [~h for h in halo]
+ ids = [f["g_ids"] for f in files]
+ recv_p = [f["recv"].astype(np.int64) for f in files]
+ river_p = [f["river"].astype(bool) for f in files]
+ xyz_p = [f["g_xyz"] for f in files]
+ halo_xyz = np.concatenate([x[h] for x, h in zip(xyz_p, halo)])
+ order = np.argsort(np.concatenate([i_[m] for i_, m in zip(ids, inner)]))
+ area_of = np.concatenate([np.full(int(m.sum()), p, np.int32) for p, m in enumerate(inner)])[order]
+ # halo cells rivers leave through (for the river net): per part, their indices
+ ends = []
+ for m, h, r, rv in zip(inner, halo, recv_p, river_p):
+ out = np.where(m & rv & h[r])[0]
+ ends.append((out, *np.unique(r[out], return_inverse=True)) if len(out) else None)
+ keys = set.intersection(*(set(f.files) for f in files))
+ a, end_vals = {}, {}
+ for k in sorted(keys):
+ vs = [f[k] for f in files]
+ if any(len(v) != len(i_) for v, i_ in zip(vs, ids)):
+ continue
+ a[k] = keep(f"a.{k}", np.concatenate([v[m] for v, m in zip(vs, inner)])[order])
+ if k in NET_KEYS:
+ end_vals[k] = [v[e[1]] if e is not None else v[:0] for v, e in zip(vs, ends)]
+ del vs
+ finally:
+ for f in files:
+ f.close()
+ # receivers index into each part's own arrays: remap to the sorted inner cells (halo/outside → self)
+ inv = np.empty_like(order)
+ inv[order] = np.arange(len(order))
+ recv_all, off = [], 0
+ for r, m in zip(recv_p, inner):
+ new = -np.ones(len(m), np.int64)
+ new[np.where(m)[0]] = np.arange(off, off + m.sum())
+ rr = new[r[m]]
+ recv_all.append(np.where(rr >= 0, rr, np.arange(off, off + m.sum())))
+ off += m.sum()
+ a["recv"] = keep("a.recv", inv[np.concatenate(recv_all)][order])
+ ids_all = a["g_ids"]
+ z_env = shoulders(a, R) # plateau across river cells
+ lake = np.asarray(a["lake"]).astype(bool)
+ lake_level = np.where(lake, a["z_filled_m"], np.nan)
+ # each lake cell's bed (+ LAKE_FLOOR_M for the fine detail): ground far below it lies past a dam or a cliff,
+ # not under water (no water walls hanging over a drop)
+ lake_floor = np.where(lake, lake_level - np.asarray(a["depression_depth_m"], dtype=np.float64) - LAKE_FLOOR_M,
+ np.nan)
+ # the river net runs on into the halo cells where rivers leave, so they meet the world's rivers at the edge
+ # (built from the few arrays it needs: a copy of every field would double the memory)
+ n = len(ids_all)
+ net_recv = a["recv"].copy()
+ extra, base = [], n
+ for part, e in enumerate(ends):
+ if e is None:
+ continue
+ out, h, k = e
+ net_recv[np.searchsorted(ids_all, ids[part][out])] = base + k
+ extra.append(part)
+ base += len(h)
+ nkeys = [k for k in NET_KEYS if k in a]
+ net_a = {k: np.concatenate([a[k], *[end_vals[k][p] for p in extra]]) for k in nkeys if k != "recv"}
+ net_a["recv"] = np.concatenate([net_recv, np.arange(n, base)])
+ net_a["river"] = np.concatenate([a["river"].astype(bool), np.zeros(base - n, bool)])
+ net_a["lake"] = np.concatenate([a["lake"].astype(bool), np.zeros(base - n, bool)])
+ ground = np.concatenate([z_env, net_a["z_surface_m"][n:]])
+ arrays = {f"a.{k}": v for k, v in a.items()}
+ arrays.update(halo_xyz=keep("halo_xyz", halo_xyz), area=keep("area", area_of), z_env=keep("z_env", z_env),
+ lake_level=keep("lake_level", lake_level), lake_floor=keep("lake_floor", lake_floor))
+ meta = {"area_names": [p.name for p in dirs], "res": h3.get_resolution(int(ids_all[0])),
+ "spacing_km": float(np.sqrt(np.mean(a["g_area_km2"])) * 1.07), "net": None}
+ try:
+ import rivers as RV
+ net = RV.RiverNet(net_a, legends, R, levels=net_a["z_surface_m"], ground=ground)
+ st, meta["net"] = net.state()
+ arrays.update({f"net.{k}": v for k, v in st.items()})
+ except Exception:
+ meta["net"] = None
+ return arrays, meta
+
+
+CODE_FILES = (Path(__file__), Path(__file__).with_name("rivers.py")) # what computes a region set's served arrays
+
+
+def code_key() -> str:
+ """Hash of the code that computes a region set's arrays: new code, new serve cache (stored river nets, shoulders
+ and lake floors are values of this code, not only of the areas' results)."""
+ h = hashlib.sha1()
+ for p in CODE_FILES:
+ h.update(Path(p).read_bytes())
+ return h.hexdigest()[:8]
+
+
+class _AreaTrees:
+ """Per-area KD-trees of a RegionSet, built on first use (one area: the set's main tree)."""
+ def __init__(self, rs):
+ self.rs, self._built = rs, {}
+
+ def __contains__(self, name) -> bool:
+ return name in self.rs.area_names
+
+ def __getitem__(self, name):
+ if name not in self._built:
+ rs = self.rs
+ if len(rs.area_names) == 1:
+ self._built[name] = rs.tree
+ else:
+ k = rs.area_names.index(name)
+ self._built[name] = cKDTree(np.asarray(rs.arrays["g_xyz"], dtype=np.float64)[np.asarray(rs.area) == k])
+ return self._built[name]
+
+
+class RegionSet:
+ """All refined areas built for the current world: fine cells for tiles, 3D heights and the inspector. The arrays
+ come from the serve cache (memory-mapped; computed and written on first load); search trees are built on first
+ use (warm() builds them before render workers fork, so they share them)."""
+ @classmethod
+ def none(cls) -> "RegionSet":
+ """No refined areas (e.g. for a build, which needs only the world's relief)."""
+ rs = cls.__new__(cls)
+ rs.fingerprint, rs.area_names, rs.empty, rs.R, rs.cache = "", [], True, 0.0, None
+ rs.area_keys = {}
+ rs._tree = rs._halo_tree = None
+ rs._lock = threading.Lock()
+ rs.area_trees = _AreaTrees(rs)
+ return rs
+
+ def __init__(self, regions_root: Path, key: str, legends: dict, radius_km: float, use_cache: bool = True):
+ self.R = float(radius_km)
+ dirs = built_areas(regions_root, key)
+ self.fingerprint = regions_fingerprint(regions_root, key, dirs)
+ self.area_names = [p.name for p in dirs] # area hashes
+ self.area_keys = {p.name: (Path(os.readlink(p)).name if p.is_symlink() else p.name) for p in dirs}
+ self.empty = not dirs
+ self.cache = None
+ self._tree = self._halo_tree = None
+ self._lock = threading.Lock()
+ self.area_trees = _AreaTrees(self)
+ if self.empty:
+ return
+ spill = None
+ if servecache.low_memory() and use_cache: # fields on disk while the cache is written
+ spill = Path(tempfile.mkdtemp(prefix=".spill-", dir=results_dir(regions_root, key)))
+ build = lambda: _compute_region_set(dirs, legends, self.R, spill=spill)
+ try:
+ if use_cache:
+ self.cache = servecache.cache_dir(results_dir(regions_root, key), f"{self.fingerprint}-{code_key()}")
+ arrays, meta = servecache.load_or_build(self.cache, build)
+ else:
+ arrays, meta = build()
+ self._from_cache(arrays, meta)
+ finally:
+ if spill is not None:
+ shutil.rmtree(spill, ignore_errors=True)
+ trim_memory() # the loading's scratch memory
+
+ def _from_cache(self, arrays: dict, meta: dict) -> None:
+ import rivers as RV
+ self.arrays = {k[2:]: v for k, v in arrays.items() if k.startswith("a.")}
+ self.ids = self.arrays["g_ids"]
+ self.res = int(meta["res"])
+ self.spacing_km = float(meta["spacing_km"])
+ self.area = arrays["area"]
+ self._halo_xyz = arrays["halo_xyz"]
+ self.z_env, self.lake_level, self.lake_floor = arrays["z_env"], arrays["lake_level"], arrays["lake_floor"]
+ st = {k[4:]: v for k, v in arrays.items() if k.startswith("net.")}
+ self.net = RV.RiverNet.from_state(st, meta["net"]) if meta.get("net") else None
+
+ @property
+ def tree(self):
+ if self._tree is None and not self.empty:
+ with self._lock:
+ if self._tree is None:
+ self._tree = cKDTree(np.asarray(self.arrays["g_xyz"], dtype=np.float64))
+ return self._tree
+
+ @property
+ def halo_tree(self):
+ if self._halo_tree is None and not self.empty:
+ with self._lock:
+ if self._halo_tree is None:
+ self._halo_tree = cKDTree(np.asarray(self._halo_xyz, dtype=np.float64))
+ return self._halo_tree
+
+ def warm(self) -> None:
+ """Build the search trees now (before forking render workers, so they share them)."""
+ if self.empty:
+ return
+ self.tree
+ self.halo_tree
+ if self.net is not None:
+ self.net.tree
+
+ def nearest(self, xyz, k=1):
+ d, i = self.tree.query(xyz, k=k)
+ return i, d * self.R
+
+ def edge_km(self, xyz):
+ return self.halo_tree.query(xyz)[0] * self.R
+
+ def weight(self, xyz):
+ """0 outside an area, rising to 1 over BLEND_KM inside its edge."""
+ if self.empty:
+ return np.zeros(len(xyz))
+ d_in = self.tree.query(xyz, distance_upper_bound=1.5 * self.spacing_km / self.R)[0] * self.R # inf: far outside
+ e = self.halo_tree.query(xyz, distance_upper_bound=(2 * BLEND_KM + 2 * self.spacing_km) / self.R)[0] * self.R
+ depth = np.full(len(d_in), -np.inf)
+ m = np.isfinite(d_in)
+ depth[m] = (e[m] - d_in[m]) / 2 # ≈ distance inside the area edge; 0 on it
+ t = np.clip(depth / BLEND_KM, 0.0, 1.0) # (bounded queries: deep inside → inf → 1)
+ return t * t * (3 - 2 * t)
+
+ def touches(self, z, x, y, areas=None, margin_km: float = 0.0) -> bool:
+ """Whether tile z/x/y (with a margin for borders) can show any refined area (or one of `areas`)."""
+ if self.empty or (areas is not None and not areas):
+ return False
+ trees = [self.tree] if areas is None else [self.area_trees[h] for h in areas if h in self.area_trees]
+ return touches_trees(trees, self.spacing_km, self.R, z, x, y, margin_km)
+
+ def index_of(self, lat, lon):
+ if self.empty:
+ return None
+ c = np.uint64(h3.latlng_to_cell(float(lat), float(lon), self.res))
+ i = int(np.searchsorted(self.ids, c))
+ return i if i < len(self.ids) and self.ids[i] == c else None