diff options
| author | godosa <godosa@godosa.eu> | 2026-10-06 23:52:03 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-06 23:52:03 +0200 |
| commit | 346b1c5195bffc71ceaa9262453e3c189656400b (patch) | |
| tree | 01ac0d31e2724cd6abcc689a5a228e2cbea2f6cf /mapgen/hydrology.py | |
| download | worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.tar.gz worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.zip | |
worldgen: initial public history
Diffstat (limited to 'mapgen/hydrology.py')
| -rw-r--r-- | mapgen/hydrology.py | 231 |
1 files changed, 231 insertions, 0 deletions
diff --git a/mapgen/hydrology.py b/mapgen/hydrology.py new file mode 100644 index 0000000..d7adde1 --- /dev/null +++ b/mapgen/hydrology.py @@ -0,0 +1,231 @@ +"""Stage `hydrology`: depression filling, lakes vs endorheic basins, discharge, rivers, Strahler order.""" +from __future__ import annotations + +import numpy as np +from scipy import sparse +from scipy.sparse import csgraph + +from .config import params +from .crust import RIFT +from .graph import accumulate, components, distance_to, priority_flood, receiver_levels, steepest_receivers +from .noise import fbm +from .pipeline import StageError + +DEFAULTS = {"fill_eps_m": 0.01, "min_depth_m": 1.0, "river_min_km3_yr": 2.0, + "salt_flat_max_p_mm": 300.0, "salt_flat_fraction": 0.2, "dry_lake_fraction": 0.05, + # lake beds: deepest point = k × area^exp m (Earth-like: ≈95 m at + # 1,000 km², ≈230 m at 20,000 km², ≈610 m at 500,000 km²) × 0.5–2 (noise at the lake), × rift_x in + # rifts, × arid_x for dry terminal lakes; never shallower than lake_min_m (refinement keeps ≥ 15 m) + "lake_depth_k": 12.0, "lake_depth_exp": 0.3, "lake_min_m": 25.0, "lake_max_m": 1800.0, + "lake_rift_x": 2.5, "lake_arid_x": 0.4, "lake_arid_p_mm": 400.0, "lake_shore": 0.25} + + +def strahler(recv, levels, river): + n = len(recv) + order = np.zeros(n, np.int8) + mx = np.zeros(n, np.int8) + cnt = np.zeros(n, np.int16) + for lv in reversed(levels): + cells = lv[river[lv]] + if len(cells) == 0: + continue + order[cells] = np.where(cnt[cells] >= 2, mx[cells] + 1, np.maximum(mx[cells], 1)) + nonroot = cells[recv[cells] != cells] + r, o = recv[nonroot], order[nonroot] + np.maximum.at(mx, r, o) + np.add.at(cnt, r, (o == mx[r]).astype(np.int16)) + return order + + +def _groups(lab): + idx = np.argsort(lab, kind="stable") + ls = lab[idx] + start = np.searchsorted(ls, 0) + idx, ls = idx[start:], ls[start:] + cuts = np.flatnonzero(np.diff(ls)) + 1 + return np.split(idx, cuts) if len(idx) else [] + + +def _leaves(recv, lab, x, limit=100000): + """Does the flow from depression cell x leave its depression for good (not back in over shallow ground)?""" + own, y = lab[x], recv[x] + for _ in range(limit): + if lab[y] == own: + return False + if lab[y] >= 0 or recv[y] == y: + return True + y = recv[y] + return True + + +def _leaves_all(recv, lab, levels, limit=100000): + """_leaves for every cell at once: walking down from recv[x], the first cell that is in a depression or a root + (stop) and how many steps away it is; x leaves unless that cell is in x's own depression (or the walk is longer + than limit steps).""" + n = len(recv) + stop, d = np.arange(n), np.zeros(n, np.int64) + for lv in levels[1:]: # roots first: a cell's receiver is done before it + free = lv[lab[lv] < 0] + stop[free] = stop[recv[free]] + d[free] = d[recv[free]] + 1 + y = recv + return (d[y] >= limit) | (lab[stop[y]] != lab) + + +def _route_inside(g, dep, lab, recv, targets): + """Within each depression, point every cell along the shortest intra-depression path to its target cell.""" + e = dep[g.src] & dep[g.dst] & (lab[g.src] == lab[g.dst]) + m = sparse.csr_matrix((g.edge_km[e], (g.src[e], g.dst[e])), shape=(g.n, g.n)) + _, pred, _ = csgraph.dijkstra(m, directed=False, indices=targets, min_only=True, return_predecessors=True) + out = recv.copy() + inside = dep & (pred >= 0) + out[inside] = pred[inside] + return out + + +def _sweep(recv, levels, water, outlets, cap): + """Accumulate flow upstream→downstream; at each spilling-lake outlet remove up to `cap` (lake evaporation).""" + acc = np.asarray(water, dtype=np.float64).copy() + loss = np.zeros(len(acc)) + is_out = np.zeros(len(acc), bool) + is_out[outlets] = True + cap_cell = np.zeros(len(acc)) + cap_cell[outlets] = cap + buf = np.zeros(len(acc)) # per level only the receivers change (graph.accumulate): same floats + if len(levels) > 1: + acc += 0.0 + for lv in reversed(levels[1:]): + push = acc[lv].copy() + o = is_out[lv] + if o.any(): + cells = lv[o] + lost = np.minimum(acc[cells], cap_cell[cells]) + loss[cells] = lost + push[o] = acc[cells] - lost + r = recv[lv] + buf[r] = 0.0 + np.add.at(buf, r, push) + acc[r] = acc[r] + buf[r] + return acc, loss + + +def lake_levels(z, zf, lab, lake, lake_id, endo): + """Each lake cell's water level (NaN elsewhere): a spilling lake stands at its spill height; a terminal lake at the + lowest ground of its basin it does not cover (the next cell to flood). Carving the beds leaves both unchanged.""" + level = np.full(len(z), np.nan) + m = int(lab.max()) + 1 if len(lab) and lab.max() >= 0 else 0 + dry = np.full(m, np.inf) # per depression: its lowest uncovered ground + sel = (lab >= 0) & ~lake + np.minimum.at(dry, lab[sel], z[sel]) + for c in _groups(np.where(lake, lake_id, -1)): + k = lab[c[0]] + level[c] = dry[k] if endo[c[0]] and k >= 0 and np.isfinite(dry[k]) else zf[c].min() + return level + + +def lake_beds(g, z, level, lake, lake_id, endo, rift, p_ann, seed, P, only=None): + """Lake beds carved below their level (heights unchanged elsewhere): each lake's deepest point from its area + (P lake_*), noise keyed by where the lake lies (not its id), the depth rising from lake_shore × that at the shore + to all of it at the cell farthest from shore; never above the ground (min), never shallower than lake_min_m. + only: carve just the lakes touching these cells (eras: where events changed the ground).""" + z = np.asarray(z, dtype=np.float64).copy() + if not lake.any(): + return z + shore = distance_to(g, ~lake) if (~lake).any() else np.full(g.n, g.spacing_km) + for c in _groups(np.where(lake, lake_id, -1)): + if only is not None and not only[c].any(): + continue + area = g.area_km2[c].sum() + ctr = g.xyz[c].mean(0) + ctr = ctr / max(np.linalg.norm(ctr), 1e-12) + d = P["lake_depth_k"] * area ** P["lake_depth_exp"] * 2.0 ** float(fbm(ctr[None], seed + 4421, 3, 6.0)[0] * 1.4) + if rift[c].mean() > 0.3: + d *= P["lake_rift_x"] + if endo[c[0]] and p_ann[c].mean() < P["lake_arid_p_mm"]: + d *= P["lake_arid_x"] + d = float(np.clip(d, P["lake_min_m"], P["lake_max_m"])) + t = shore[c] / max(shore[c].max(), 1e-9) + prof = np.maximum(d * (P["lake_shore"] + (1 - P["lake_shore"]) * t ** 0.6), P["lake_min_m"]) + z[c] = np.minimum(z[c], level[c] - prof) + return z + + +def run(ctx) -> dict: + g = ctx.grid + P = params(ctx.cfg, "hydrology", DEFAULTS) + z, p_ann, pet = ctx.need("elevation_eroded_m", "P_ann", "PET") + z = z.astype(np.float64) + ar = np.arange(g.n) + ocean = np.asarray(ctx.data["ocean"]) if "ocean" in ctx.data else z <= 0 + aet = p_ann / np.sqrt(1.0 + (p_ann / np.maximum(pet, 1e-6)) ** 2) # Pike (1964) + runoff = np.where(ocean, 0.0, np.maximum(p_ann - aet, 0.0)) + water = runoff * g.area_km2 * 1e-6 # km³/yr per cell + zf = priority_flood(g, z, ocean, P["fill_eps_m"]) + depth = zf - z + dep = ~ocean & (depth > P["min_depth_m"]) + lab = components(g, dep) + recv1, _, _ = steepest_receivers(g, zf) + recv1 = np.where(ocean, ar, recv1) + levels1 = receiver_levels(recv1) + q1 = accumulate(recv1, levels1, water) + evap_net = np.maximum(pet - p_ann, 0.0) * g.area_km2 * 1e-6 # full-lake evaporation, km³/yr + + groups = _groups(lab) + leaves = _leaves_all(recv1, lab, levels1) if len(groups) else None + outlets = np.zeros(len(groups), np.int64) + terminals = np.zeros(len(groups), np.int64) + cap = np.zeros(len(groups)) + for k, c in enumerate(groups): + ext = c[lab[recv1[c]] != lab[c[0]]] + ext = ext[leaves[ext]] + outlets[k] = ext[np.argmax(q1[ext])] if len(ext) else c[np.argmax(q1[c])] + terminals[k] = c[np.argmin(z[c])] + cap[k] = evap_net[c].sum() + + # 1) every depression drains to its spill outlet; one upstream-first sweep decides spill vs endorheic + recv_a = _route_inside(g, dep, lab, recv1, outlets) if len(groups) else recv1 + acc_a, _ = _sweep(recv_a, receiver_levels(recv_a), water, outlets, cap) + inflow = acc_a[outlets] + spill = inflow >= cap + # 2) endorheic depressions drain to their single lowest cell instead + targets = np.where(spill, outlets, terminals) + recv = _route_inside(g, dep, lab, recv1, targets) if len(groups) else recv1 + recv[terminals[~spill]] = terminals[~spill] + try: + levels = receiver_levels(recv) + except ValueError as e: + raise StageError(f"hydrology: {e}") from e + q, loss = _sweep(recv, levels, water, outlets[spill], cap[spill]) + + lake = np.zeros(g.n, bool) + endo = np.zeros(g.n, bool) + salt = np.zeros(g.n, bool) + lake_id = np.full(g.n, -1, np.int32) + for k, c in enumerate(groups): + if spill[k]: + lake[c] = True + lake_id[c] = k + continue + endo[c] = True + cs = c[np.argsort(z[c])] + nl = int(np.searchsorted(np.cumsum(evap_net[cs]), inflow[k])) + if inflow[k] > 0: + nl = max(nl, 1) # the terminal always holds some water + lake[cs[:nl]] = True + lake_id[cs[:nl]] = k + if nl == 0 or (nl < P["dry_lake_fraction"] * len(cs) and p_ann[c].mean() < P["salt_flat_max_p_mm"]): + a = np.cumsum(g.area_km2[cs]) + ns = max(nl + 1, int(np.searchsorted(a, P["salt_flat_fraction"] * a[-1]))) + salt[cs[nl:ns]] = True + river = ~ocean & ~lake & (q >= P["river_min_km3_yr"]) + level = lake_levels(z, zf, lab, lake, lake_id, endo) + rift = np.asarray(ctx.data["age_class"]) == RIFT if "age_class" in ctx.data else np.zeros(g.n, bool) + zb = lake_beds(g, z, level, lake, lake_id, endo, rift, p_ann, ctx.seed, P, ctx.data.get("lake_carve")) + cut = np.asarray(ctx.data.get("lake_cut_m", 0.0), dtype=np.float64) + (z - zb) # sea masks see the uncut ground + z = zb + depth = zf - z + dtype = np.asarray(ctx.data["elevation_eroded_m"]).dtype + return {"elevation_eroded_m": z.astype(dtype), "lake_level_m": level.astype(np.float32), + "lake_cut_m": np.broadcast_to(cut, z.shape).astype(np.float32), "z_filled_m": zf, "recv": recv, "discharge_km3_yr": q, "runoff_mm": runoff, "aet_mm": aet, + "lake": lake, "lake_id": lake_id, "endorheic": endo, "salt_flat": salt, "river": river, + "strahler": strahler(recv, levels, river), "depression_depth_m": depth, "lake_loss_km3_yr": loss} |
