aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/seafloor.py
blob: 86791e512479e6921a67212f3b788fe3aa5b4234 (plain)
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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
"""Sea-floor detail for refined areas:
turbidity canyons and their fans, sediment ponds, fine volcanic fields on the sunken plateaus, vents (data) and the
refined sea-floor fields. Only sea cells inside the area change, and never above SEA_TOP_M; land and the halo keep
their heights. Deterministic: the same cells and world values give the same floor. Generated terrain (`idea`)."""
from __future__ import annotations

import json
from pathlib import Path

import numpy as np
from scipy.spatial import cKDTree

import h3par
import worldgen_path  # noqa: F401  (mapgen on sys.path)
from mapgen import plateaus as PL, seabed as SB
from mapgen.graph import accumulate, components, priority_flood, receiver_levels, smooth_km, steepest_receivers
from mapgen.noise import name_seed
from mapgen.sphere import east_north, gc_dist_km, great_circle_point, latlon_to_xyz
from mapgen.zones import smootherstep

SEA_TOP_M = -5.0               # the pass never lifts a sea cell above this (no new land)
WALL_M = 1.0e7                 # land in the sea-floor routing
CANYON = {"k": 0.1, "m": 0.5, "n": 1.0, "min_area_km2": 500.0, "min_slope": 2.0, "cap_m": 1500.0}   # slope: m/km
FAN = {"flat_slope": 1.0, "max_m": 100.0, "km": 15.0}
POND = {"min_depth_m": 50.0, "fill": 0.3}
VENT_TYPES = ["black smoker", "white smoker", "diffuse", "cold seep"]
VENT_MINERALS = ["copper-iron sulfides", "zinc sulfides and barite", "iron-manganese oxides", "methane carbonates"]
VENT = {"rate": 0.03, "seep_rate": 0.002, "min_depth_m": 200.0, "ridge_km": 60.0, "reach_km": 3.0}
TEMP_C = (np.array([300.0, 100.0, 10.0, 0.0]), np.array([100.0, 200.0, 90.0, 5.0]))   # per type: from, span


def canyons(g, z, sea, halo, P=CANYON, F=FAN):
    """(heights, canyon depth, fan thickness). Turbidity flows follow the steepest descent over the sea floor (land is
    a wall); where their contributing sea area and slope are high they cut (stream-power form, like rivers, capped
    at cap_m); the material they carry settles as a fan where they first reach flat floor (slope < flat_slope),
    spread over ≈ km and at most max_m thick. Canyons only lower, fans only raise; the halo stays."""
    z = np.asarray(z, dtype=np.float64)
    sea, halo = np.asarray(sea, bool), np.asarray(halo, bool)
    active = sea & ~halo
    recv, slope, _ = steepest_receivers(g, np.where(sea, z, WALL_M))
    levels = receiver_levels(recv)
    area = accumulate(recv, levels, np.where(sea, g.area_km2, 0.0))
    cut = np.where(active & (area >= P["min_area_km2"]) & (slope >= P["min_slope"]),
                   np.minimum(P["cap_m"], P["k"] * area ** P["m"] * slope ** P["n"]), 0.0)
    vol = accumulate(recv, levels, cut * g.area_km2 / 1000.0)                  # km³ carried down
    flat = active & (slope < F["flat_slope"])
    ar = np.arange(g.n)
    src = active & ~flat & (recv != ar) & (vol > 0)
    entry = np.zeros(g.n, bool)
    entry[recv[src]] = True
    entry &= flat
    dep = np.where(entry, vol * 1000.0 / g.area_km2, 0.0)                       # m over the entry cell
    fan = np.where(active & (cut == 0), np.clip(smooth_km(g, dep, F["km"]), 0.0, F["max_m"]), 0.0)
    zc = z - cut
    zn = np.where(fan > 0, np.maximum(zc, np.minimum(zc + fan, SEA_TOP_M)), zc)
    return zn, cut, zn - zc


def ponds(g, z, sea, halo, sediment_m, P=POND):
    """(heights, fill thickness): closed sea-floor hollows deeper than min_depth_m below their spill level fill
    with sediment to a flat floor at min(spill level, lowest point + fill × the mean sediment there). Never above
    the spill level, never lower."""
    z = np.asarray(z, dtype=np.float64)
    sea, halo = np.asarray(sea, bool), np.asarray(halo, bool)
    out = z.copy()
    if not (sea & ~halo).any():
        return out, np.zeros(g.n)
    sinks = sea & halo
    if not sinks.any():                                       # sea closed inside the area: its deepest cell drains
        sinks = np.zeros(g.n, bool)
        s = np.flatnonzero(sea)
        sinks[s[np.argmin(z[s])]] = True
    zf = priority_flood(g, np.where(sea, z, WALL_M), sinks, eps=0.0)
    hollow = sea & ~halo & (zf - z > 0.5)
    if not hollow.any():
        return out, np.zeros(g.n)
    lab = components(g, hollow)
    idx = np.flatnonzero(hollow)
    L = lab[idx]
    n = int(L.max()) + 1
    low = np.full(n, np.inf)
    np.minimum.at(low, L, z[idx])
    spill = np.full(n, -np.inf)
    np.maximum.at(spill, L, zf[idx])
    cnt = np.bincount(L, minlength=n)
    sup = np.bincount(L, weights=np.asarray(sediment_m, dtype=np.float64)[idx], minlength=n) / np.maximum(cnt, 1)
    level = np.minimum(spill, low + P["fill"] * sup)
    deep = (spill - low) >= P["min_depth_m"]
    lv = np.where(deep[L], np.minimum(level[L], SEA_TOP_M), -np.inf)
    out[idx] = np.maximum(z[idx], lv)
    return out, out - z


def _u(ids, salt: int) -> np.ndarray:
    """Uniform [0, 1) per H3 cell id (splitmix64): a cell draws the same number in every area and every era."""
    x = np.asarray(ids, dtype=np.uint64) + np.uint64((int(salt) * 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF)
    x = x + np.uint64(0x9E3779B97F4A7C15)
    x = (x ^ (x >> np.uint64(30))) * np.uint64(0xBF58476D1CE4E5B9)
    x = (x ^ (x >> np.uint64(27))) * np.uint64(0x94D049BB133111EB)
    x = x ^ (x >> np.uint64(31))
    return (x >> np.uint64(11)).astype(np.float64) / float(1 << 53)


def _around(rng, lat, lon, reach_km, radius_km):
    c = latlon_to_xyz(lat, lon)
    e, n = east_north(c[None])
    az = rng.uniform(0.0, 2.0 * np.pi)
    return great_circle_point(c, np.cos(az) * n[0] + np.sin(az) * e[0], rng.uniform(0.0, reach_km), radius_km)


def small_features(p: dict, feat: dict, seed: int, radius_km: float) -> list:
    """A plateau's fine volcanic features, deterministic per name and independent of any area: 2–6 cones (2–15 km
    wide, 0.2–2 km tall) around each world-scale cone, 1–2 pit calderas in each world caldera, and a fissure ridge
    from the hotspot to each caldera (the hotspot's feeding track)."""
    rng = np.random.default_rng(name_seed(seed, p["name"]) + 11)
    vent = float(p.get("vent", 1.0))
    out = []
    for c in feat["cones"]:
        for _ in range(int(rng.integers(2, 7))):
            out.append({"kind": "cone", "at": _around(rng, c["lat"], c["lon"], c["radius_km"], radius_km),
                        "radius_km": float(rng.uniform(1.0, 7.5)), "height_m": float(rng.uniform(200.0, 2000.0)) * vent})
    for c in feat["calderas"]:
        for _ in range(int(rng.integers(1, 3))):
            out.append({"kind": "pit", "at": _around(rng, c["lat"], c["lon"], 0.5 * c["radius_km"], radius_km),
                        "radius_km": float(rng.uniform(2.0, 6.0)), "height_m": 150.0 * vent, "floor_m": -250.0 * vent})
    hot = latlon_to_xyz(*feat["hotspot"])
    for c in feat["calderas"]:
        out.append({"kind": "fissure", "at": hot, "to": latlon_to_xyz(c["lat"], c["lon"]), "radius_km": 1.5,
                    "height_m": float(rng.uniform(100.0, 400.0)) * vent})
    return out


def _segment_km(xyz, a, b, radius_km, step_km=1.0):
    n = max(2, int(gc_dist_km(a, b, radius_km) / step_km) + 1)
    t = np.linspace(0.0, 1.0, n)[:, None]
    pts = a * (1.0 - t) + b * t
    pts /= np.linalg.norm(pts, axis=1, keepdims=True)
    chord, _ = cKDTree(pts).query(xyz, workers=max(1, h3par.workers()))
    return 2.0 * np.arcsin(np.clip(chord / 2.0, 0.0, 1.0)) * radius_km


def volcanic(g, sea, halo, plateau_id, plateaus: list, seed: int):
    """(height to add, nearness 0–1 to a feature) from the fine volcanic features of the plateaus under the area's
    sea cells (vent = 0: none)."""
    add, near = np.zeros(g.n), np.zeros(g.n)
    for k, p in enumerate(plateaus):
        on = np.flatnonzero((np.asarray(plateau_id) == k) & sea & ~halo)
        if not len(on) or float(p.get("vent", 1.0)) <= 0:
            continue
        xyz = g.xyz[on]
        for f in small_features(p, PL.features(p, seed, g.radius_km), seed, g.radius_km):
            r = f["radius_km"]
            if f["kind"] == "fissure":
                d = _segment_km(xyz, f["at"], f["to"], g.radius_km)
                h = f["height_m"] * np.exp(-(d / r) ** 2)
            else:
                d = gc_dist_km(xyz, f["at"], g.radius_km)
                if f["kind"] == "cone":
                    h = f["height_m"] * np.clip(1.0 - d / r, 0.0, 1.0) ** 1.5
                else:
                    h = f["height_m"] * np.exp(-((d - r) / (0.25 * r)) ** 2) + f["floor_m"] * (1.0 - smootherstep(d / r))
            add[on] += h
            near[on] = np.maximum(near[on], np.exp(-(d / (r + 3.0)) ** 2))
    return add, near


def clamp_plateaus(z, sea, halo, plateau_id, plateaus: list):
    """Hidden plateaus keep every sea cell at least HIDDEN_MAX_M (−550 m) deep."""
    z = np.asarray(z, dtype=np.float64).copy()
    for k, p in enumerate(plateaus):
        if not p.get("islands", False):
            m = (np.asarray(plateau_id) == k) & sea & ~halo
            z[m] = np.minimum(z[m], PL.HIDDEN_MAX_M)
    return z


def vents(g, z, sea, halo, potential, near, d_div_km, sediment_m, bottom_c, seed: int, V=VENT):
    """(per-cell vent strength 0–1, vents). A sea cell deeper than min_depth_m holds a vent with probability
    rate × potential × (0.5 + nearness to a volcanic feature); on a ridge axis (d_div_km < ridge_km) vents are black
    smokers, near a feature black (60 %) or white smokers, elsewhere diffuse; cold seeps sit in thick sediment where
    the potential is low. Temperature and flow class (0–2) per vent; the strength spreads ≈ reach_km around each."""
    depth = -np.asarray(z, dtype=np.float64)
    cand = sea & ~halo & (depth > V["min_depth_m"])
    pot = np.clip(np.asarray(potential, dtype=np.float64), 0.0, 1.0)
    near = np.asarray(near, dtype=np.float64)
    u = [_u(g.ids, seed * 8 + s) for s in range(5)]
    hot = cand & (u[0] < V["rate"] * pot * (0.5 + near))
    seep = cand & ~hot & (pot < 0.2) & (np.asarray(sediment_m) > 1000.0) & (u[1] < V["seep_rate"])
    i = np.flatnonzero(hot | seep)
    ridge = np.asarray(d_div_km)[i] < V["ridge_km"]
    close = near[i] > 0.3
    t = np.where(seep[i], 3, np.where(ridge | (close & (u[2][i] < 0.6)), 0, np.where(close, 1, 2))).astype(np.int8)
    lo, span = TEMP_C
    temp = np.where(t == 3, np.asarray(bottom_c, dtype=np.float64)[i], 0.0) + lo[t] + span[t] * u[3][i]
    flow = np.minimum((3.0 * u[4][i] * np.sqrt(pot[i])).astype(np.int8), 2).astype(np.int8)
    strength = np.zeros(g.n)
    if len(i):
        reach = V["reach_km"] / g.radius_km
        d, k = cKDTree(g.xyz[i]).query(g.xyz, distance_upper_bound=3.0 * reach, workers=max(1, h3par.workers()))
        ok = np.isfinite(d) & sea
        w = 0.5 + 0.25 * flow
        strength[ok] = np.clip(w[k[ok]] * np.exp(-(d[ok] / reach) ** 2), 0.0, 1.0)
    out = {"cell": g.ids[i].astype(np.uint64), "lat": g.lat[i].astype(np.float64), "lon": g.lon[i].astype(np.float64),
           "type": t, "temp_c": temp.astype(np.float32), "flow": flow, "mineral": t.copy()}
    return strength, out


def fields(pv: dict, sea, halo, cut, fan, fill, volc, strength) -> dict:
    """The refined sea-floor fields (0 / none on land): the world cell's values; sea over a world land cell (a refined
    coast) starts as terrigenous sediment; canyons scour (their fans and pond fill add sediment) and read as
    terrigenous; fine volcanic features read as volcanic floor; vent fields over the vents (sulfides, no sediment,
    warmer water)."""
    sea = np.asarray(sea, bool)
    t = np.asarray(pv["seabed_type"]).astype(np.int8).copy()
    m = np.asarray(pv["seabed_mineral"]).astype(np.int8).copy()
    sed = np.asarray(pv["sediment_m"], dtype=np.float64).copy()
    bt = np.asarray(pv["bottom_temp_c"], dtype=np.float64).copy()
    orphan = sea & (t == SB.SB_NONE)
    t[orphan] = SB.SB_TERRIGENOUS
    sed[orphan] = 300.0
    bt[orphan] = np.maximum(np.asarray(pv["T_mean"], dtype=np.float64)[orphan], -1.8)
    cut, fan, fill = (np.asarray(a, dtype=np.float64) for a in (cut, fan, fill))
    volc, strength = np.asarray(volc, dtype=np.float64), np.asarray(strength, dtype=np.float64)
    sed = np.where(cut > 0, 0.3 * sed, sed) + fan + fill
    t[(cut > 50.0) | (fan > 1.0)] = SB.SB_TERRIGENOUS
    t[np.abs(volc) > 50.0] = SB.SB_VOLCANIC
    vf = strength > 0.5
    t[vf] = SB.SB_VENTS
    m[vf] = SB.MI_SULFIDES
    sed[vf] = 0.0
    bt = bt + 20.0 * strength
    land = ~sea
    t[land], m[land] = SB.SB_NONE, SB.MI_NONE
    z32 = lambda a: np.where(land, 0.0, a).astype(np.float32)
    return {"seabed_type": t, "seabed_mineral": m, "bottom_temp_c": z32(bt), "sediment_m": z32(sed),
            "vent": z32(strength), "canyon_m": z32(cut), "fan_m": z32(fan)}


PV_KEYS = ("vent_potential", "seabed_type", "seabed_mineral", "bottom_temp_c", "sediment_m", "d_div_km", "T_mean")


def run(g, z, sea, halo, parent, world_a, plateaus: list, seed: int):
    """The whole pass: (heights, refined sea-floor fields incl. plateau_id, vents)."""
    sea, halo = np.asarray(sea, bool), np.asarray(halo, bool)
    pv = {k: np.asarray(world_a[k])[parent] for k in PV_KEYS}
    plateau_id = (PL.cell_ids(g.xyz, plateaus, seed, g.radius_km) if plateaus
                  else np.full(g.n, -1, np.int16))
    z0 = np.asarray(z, dtype=np.float64)
    zc, cut, fan = canyons(g, z0, sea, halo)
    zp, fill = ponds(g, zc, sea, halo, pv["sediment_m"])
    add, near = volcanic(g, sea, halo, plateau_id, plateaus, seed)
    zv = np.where(sea & ~halo, np.minimum(zp + add, np.maximum(zp, SEA_TOP_M)), zp)
    zn = clamp_plateaus(zv, sea, halo, plateau_id, plateaus)
    strength, vt = vents(g, zn, sea, halo, pv["vent_potential"], near, pv["d_div_km"], pv["sediment_m"],
                         pv["bottom_temp_c"], seed)
    f = fields(pv, sea, halo, cut, fan, fill, zv - zp, strength)
    f["plateau_id"] = plateau_id.astype(np.int16)
    return zn, f, vt


VENT_RGB = [(230, 40, 30), (245, 245, 245), (250, 160, 40), (40, 220, 230)]   # by VENT_TYPES


def preview(area_dir: Path, path: Path, px: int = 1400) -> Path:
    """A shaded depth image of a refined area (its box, north up) with its vents as dots — black smokers red, white
    smokers white, diffuse orange, cold seeps cyan: a first look at a sea floor before the viewer shows it."""
    from PIL import Image, ImageDraw
    area_dir, path = Path(area_dir), Path(path)
    with np.load(area_dir / "cells.npz") as a:
        inner = ~a["halo"].astype(bool)
        lat, lon = a["g_lat"][inner], a["g_lon"][inner]
        z, sea = a["elevation_eroded_m"][inner].astype(np.float64), a["ocean"][inner].astype(bool)
    c0 = float(np.degrees(np.arctan2(np.sin(np.radians(lon)).mean(), np.cos(np.radians(lon)).mean())))
    x = (lon - c0 + 180.0) % 360.0 - 180.0
    k = float(np.cos(np.radians(lat.mean())))
    W = int(px)
    H = max(2, int(round(W * (lat.max() - lat.min()) / max((x.max() - x.min()) * k, 1e-9))))
    GX, GY = np.meshgrid(np.linspace(x.min(), x.max(), W), np.linspace(lat.max(), lat.min(), H))
    pts = np.column_stack([x * k, lat])
    tree = cKDTree(pts)
    dist, j = tree.query(np.column_stack([GX.ravel() * k, GY.ravel()]))
    spacing = float(np.median(tree.query(pts, k=2)[0][:, 1]))
    Z, S = z[j].reshape(H, W), sea[j].reshape(H, W)
    px_km = (lat.max() - lat.min()) * 111.2 / H
    gy, gx = np.gradient(Z, px_km)
    shade = np.clip(1.0 - 0.015 * (gx - gy), 0.45, 1.35)[..., None]
    t = np.clip(-Z / 6000.0, 0.0, 1.0)[..., None]
    rgb = np.where(S[..., None], (1.0 - t) * np.array([150.0, 210.0, 235.0]) + t * np.array([10.0, 30.0, 80.0]),
                   np.array([125.0, 140.0, 95.0])) * shade
    rgb[(dist > 2.0 * spacing).reshape(H, W)] = 40.0
    im = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8))
    if (area_dir / "vents.npz").exists():
        d = ImageDraw.Draw(im)
        with np.load(area_dir / "vents.npz") as v:
            vx = ((v["lon"] - c0 + 180.0) % 360.0 - 180.0 - x.min()) / max(x.max() - x.min(), 1e-9) * (W - 1)
            vy = (lat.max() - v["lat"]) / max(lat.max() - lat.min(), 1e-9) * (H - 1)
            for a_, b_, ty in zip(vx.tolist(), vy.tolist(), v["type"].tolist()):
                d.ellipse([a_ - 3, b_ - 3, a_ + 3, b_ + 3], fill=VENT_RGB[int(ty)], outline=(0, 0, 0))
    path.parent.mkdir(parents=True, exist_ok=True)
    im.save(path, quality=90)
    return path


def preview_region(root: Path, res: int, region_id: str, regions_path: Path | None = None,
                   regions_root: Path | None = None, out_dir: Path | None = None) -> Path:
    """preview() of a region's refined area in every world that has it: <out_dir>/<region>-<era>.jpg."""
    import refine as RF
    root = Path(root)
    regions = RF.load_regions(regions_path or root / "places" / "regions.json")
    if not any(RF.region_key(r) == region_id for r in regions):
        raise SystemExit(f"no region {region_id} in {regions_path or 'places/regions.json'}")
    fine = res + RF.FINER
    h = RF.region_areas(regions, RF.area_cells(regions, fine), fine)[region_id]["area"]
    rroot = Path(regions_root or root / "out" / f"r{res}" / "regions")
    out_dir = Path(out_dir or root / "previews" / f"r{res}" / "seafloor")
    made = []
    for s in sorted(p for p in rroot.glob("*-m*") if p.is_dir()):
        if h and (s / h / "cells.npz").exists():
            era = json.loads((s / "era.json").read_text())["era"] if (s / "era.json").exists() else s.name
            made.append(preview(s / h, out_dir / f"{region_id}-{era}.jpg"))
    if not made:
        raise SystemExit(f"{region_id} is not refined at res {res}: run mapgen.py refine --res {res}")
    return out_dir