raw · 10694 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 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} |