"""Stage `plates`: grow plates from seeds, assign Euler motions, classify boundaries.""" from __future__ import annotations import numpy as np from scipy.spatial import cKDTree from .config import params from .graph import nearest_source from .grid import edge_blocks from .noise import fbm from .pipeline import StageError from .sphere import motion_to_omega, velocity NONE, CONV, DIV, TRANS = 0, 1, 2, 3 DEFAULTS = {"land_cost": 0.25, "noise": 0.35, "noise_freq": 3.0, "warp_km": 1000.0, "warp_freq": 1.5, "min_rate_m_yr": 0.005} def edge_convergence(g, vel): """Per directed edge s→d: closing speed (m/yr, >0 converging) and tangential slip speed.""" t, src, dst = g.edge_tangents, g.src, g.dst along, tang = np.empty(len(dst)), np.empty(len(dst)) for s in edge_blocks(len(dst)): # per edge: the same values, small temporaries rel = vel[dst[s]] - vel[src[s]] along[s] = np.sum(rel * t[s], axis=1) tang[s] = np.linalg.norm(rel - along[s][:, None] * t[s], axis=1) return -along, tang def classify_boundaries(g, plate, vel, min_rate): conv, tang = edge_convergence(g, vel) s, d = g.src, g.dst b = plate[s] != plate[d] cnt = np.bincount(s[b], minlength=g.n) c_mean = np.bincount(s[b], weights=conv[b], minlength=g.n) / np.maximum(cnt, 1) t_mean = np.bincount(s[b], weights=tang[b], minlength=g.n) / np.maximum(cnt, 1) other = np.full(g.n, -1, np.int16) other[s[b]] = plate[d[b]] typ = np.zeros(g.n, np.int8) isb = cnt > 0 typ[isb] = TRANS typ[isb & (c_mean > min_rate)] = CONV typ[isb & (c_mean < -min_rate)] = DIV rate = np.where(typ == CONV, c_mean, np.where(typ == DIV, -c_mean, np.where(isb, t_mean, 0.0))) return typ, rate.astype(np.float32), other def _warp_labels(g, plate, seeds, P, seed, continental_kind): """Domain warp: each cell takes the label found at a noise-displaced point → meandering boundaries. Only same-kind swaps (cont↔cont, ocean↔ocean): ocean–continent margins stay where growth put them.""" if P["warp_km"] <= 0: return plate amp = P["warp_km"] / g.radius_km disp = np.stack([fbm(g.xyz, seed + 101 + k, 3, P["warp_freq"]) for k in range(3)], axis=1) disp /= max(float(disp.std()), 1e-12) # warp_km = RMS displacement per component q = g.xyz + amp * disp q /= np.linalg.norm(q, axis=1, keepdims=True) _, j = cKDTree(g.xyz).query(q) out = plate[j].astype(np.int16) keep = continental_kind[out] != continental_kind[plate] out[keep] = plate[keep] out[seeds] = np.arange(len(seeds), dtype=np.int16) return out def run(ctx) -> dict: g = ctx.grid P = params(ctx.cfg, "plates", DEFAULTS) land, land_hint = ctx.need("sk_land", "m_land_hint") plates = ctx.tect["plate"] seeds = np.array([g.cell_index(*p["seed"]) for p in plates], dtype=np.int64) if len(np.unique(seeds)) < len(seeds): raise StageError("two plate seeds fall in the same cell; move one in tectonics.toml") hint = np.clip(land + 0.5 * land_hint, 0.0, 1.0) nz = fbm(g.xyz, ctx.seed + 11, octaves=4, freq=P["noise_freq"]) mult = np.where(hint[g.dst] > 0.5, P["land_cost"], 1.0) * (1.0 + P["noise"] * nz[g.dst]) _, src = nearest_source(g, seeds, g.edge_km * np.maximum(mult, 0.05)) order = np.argsort(seeds) plate = order[np.searchsorted(seeds[order], src)].astype(np.int16) kinds = np.array([p["kind"] == "continental" for p in plates]) plate = _warp_labels(g, plate, seeds, P, ctx.seed, kinds) omegas = np.array([motion_to_omega(*p["seed"], *p["motion"], g.radius_km) for p in plates]) vel = velocity(g.xyz, omegas[plate], g.radius_km) btype, brate, bother = classify_boundaries(g, plate, vel, P["min_rate_m_yr"]) return {"plate": plate, "vel": vel, "plate_continental": kinds, "bnd_type": btype, "bnd_rate": brate, "bnd_other": bother}