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/ocean.py | |
| download | worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.tar.gz worldgen-346b1c5195bffc71ceaa9262453e3c189656400b.zip | |
worldgen: initial public history
Diffstat (limited to 'mapgen/ocean.py')
| -rw-r--r-- | mapgen/ocean.py | 166 |
1 files changed, 166 insertions, 0 deletions
diff --git a/mapgen/ocean.py b/mapgen/ocean.py new file mode 100644 index 0000000..67dfaf7 --- /dev/null +++ b/mapgen/ocean.py @@ -0,0 +1,166 @@ +"""Ocean circulation for stage `climate`: wind-driven surface currents, sea-surface temperature, Ekman upwelling +and a productivity index. + +Currents: the Stommel model on the sphere, r∇²ψ + βψ_x = k·curl(τ/ρH), solved for the stream function ψ over +every cell; land is the same fluid with `land_friction`× the friction (Brinkman penalisation), so coasts block the +flow and islands need no special treatment. u = k × ∇ψ. Western boundary currents come out ≈ r/β wide. +""" +from __future__ import annotations + +import numpy as np +from scipy import sparse +from scipy.sparse import linalg as splinalg + +from .graph import bicgstab_jacobi, distance_to, gradient, pmap, smooth_km +from .grid import rowdot_at +from .sphere import east_north + +DEFAULTS = { + "enabled": True, "friction_days": 5.0, "layer_m": 150.0, "stress_k": 1.0, "land_friction": 1000.0, + "rho_air": 1.2, "drag": 1.3e-3, "direct_max_cells": 500000, + "relax_days": 300.0, "kappa_m2s": 1000.0, + "ekman_min_lat": 3.0, "upwell_coast_km": 100.0, + "prod_upwell": 0.6, "prod_shelf": 0.4, "prod_mix": 0.3, "upwell_ref_m_yr": 100.0, + "prod_front": 0.4, "front_min": 0.5, "front_ref": 1.0, +} +RHO_W = 1025.0 +YEAR_S = 3.15576e7 + + +def omega(day_hours): + return 2.0 * np.pi / (float(day_hours) * 3600.0) + + +def divergence(g, V): + """Graph divergence of a tangent field (V per km): (2/k_i) Σ_j V_j·t_ij / d_ij (exact for linear V on a hex + lattice; the V_i terms cancel).""" + w = rowdot_at(V, g.dst, g.edge_tangents) / g.edge_km + return np.bincount(g.src, weights=w, minlength=g.n) * (2.0 / g.counts) + + +def _edge_operator(g, w): + """Sparse operator f ↦ Σ_j w_ij (f_j − f_i).""" + A = sparse.csr_matrix((w, (g.src, g.dst)), shape=(g.n, g.n)) + return A - sparse.diags(np.asarray(A.sum(axis=1)).ravel()) + + +def wind_stress(wind, P): + """Bulk formula τ = k·ρ_air·C_d·|w|·w (N/m²).""" + wind = np.asarray(wind, dtype=np.float64) + return P["stress_k"] * P["rho_air"] * P["drag"] * np.linalg.norm(wind, axis=1, keepdims=True) * wind + + +def streamfunction(g, ocean, wind, P, day_hours): + """ψ (m²/s) of the wind-driven surface flow; one direct sparse solve over every cell.""" + Om = omega(day_hours) + R = g.radius_km * 1e3 + beta = 2.0 * Om * np.cos(np.radians(g.lat)) / R # 1/(m·s) + beta30 = 2.0 * Om * np.cos(np.radians(30.0)) / R + r = max(1.0 / (P["friction_days"] * 86400.0), beta30 * g.spacing_km * 1e3) # boundary layer ≥ one cell + F = np.where(ocean[:, None], wind_stress(wind, P), 0.0) / (RHO_W * P["layer_m"]) + curl = divergence(g, np.cross(F, g.xyz)) * 1e-3 # k·curl F = ∇·(F×k), 1/s² + fr = np.where(ocean, 1.0, P["land_friction"]) + fe = 2.0 / (1.0 / fr[g.src] + 1.0 / fr[g.dst]) # harmonic mean: coasts count as land + L = _edge_operator(g, 4.0 / (g.counts[g.src] * g.edge_km ** 2) * fe) # ∇·(fr∇), per km² + e, _ = east_north(g.xyz) + Dx = _edge_operator(g, (2.0 / g.counts[g.src]) * rowdot_at(e, g.src, g.edge_tangents) / g.edge_km) + A = (L + sparse.diags(beta / r * 1e3) @ Dx).tolil() # β/r·1e3: per km + b = 1e6 * curl / r + # gauge: ψ is defined up to a constant; pin it deep inland, where land friction soaks up the solve's + # compatibility residual (pinned at sea it would leave a point vortex there) + k = int(np.argmax(distance_to(g, ocean))) if (~ocean).any() else 0 + A[k, :] = 0.0 + A[k, k] = 1.0 + b[k] = 0.0 + return splinalg.spsolve(A.tocsc(), b) + + +def velocity(g, psi): + """u = k × ∇ψ (m/s), tangent 3-vectors.""" + return np.cross(g.xyz, gradient(g, psi) * 1e-3) + + +def _coarse_currents(g, ocean, wind, P, day_hours): + """Grids too big for a direct solve: solve on the parent H3 resolution, carry u down, smooth.""" + from .grid import build_grid, cell_parents + cg = build_grid(g.res - 1, g.radius_km) + parent = np.searchsorted(cg.ids, cell_parents(g.ids, g.res - 1)) + cnt = np.maximum(np.bincount(parent, minlength=cg.n), 1) + c_ocean = np.bincount(parent, weights=ocean.astype(float), minlength=cg.n) / cnt > 0.5 + c_wind = np.stack([np.bincount(parent, weights=wind[:, k], minlength=cg.n) / cnt for k in range(3)], axis=1) + cu = currents(cg, c_ocean, c_wind, P, day_hours) + u = np.stack(pmap(lambda k: smooth_km(g, cu[parent, k], cg.spacing_km), range(3)), axis=1) + return u - np.sum(u * g.xyz, axis=1, keepdims=True) * g.xyz # back into the tangent plane + + +def currents(g, ocean, wind, P, day_hours): + """Surface current (n,3) m/s; 0 on land.""" + ocean = np.asarray(ocean, bool) + wind = np.asarray(wind, dtype=np.float64) + if not ocean.any(): + return np.zeros((g.n, 3)) + if g.n > P["direct_max_cells"]: + u = _coarse_currents(g, ocean, wind, P, day_hours) + else: + u = velocity(g, streamfunction(g, ocean, wind, P, day_hours)) + return np.where(ocean[:, None], u, 0.0) + + +def _masked_edges(g, mask, w): + return _edge_operator(g, np.where(mask[g.src] & mask[g.dst], w, 0.0)) + + +def sst(g, ocean, u, T_eq, P): + """Annual-mean sea-surface temperature (°C): steady u·∇T − κ∇²T = λ(T_eq − T) over the ocean (upwind + advection, no flux into land); land keeps T_eq.""" + ocean = np.asarray(ocean, bool) + T_eq = np.asarray(T_eq, dtype=np.float64) + lam = 1.0 / (P["relax_days"] * 86400.0) + up = (4.0 / g.counts[g.src]) * np.maximum(-rowdot_at(u, g.src, g.edge_tangents), 0.0) / (g.edge_km * 1e3) + adv = -_masked_edges(g, ocean, up) # Σ_upstream c_ij (T_i − T_j), 1/s + dif = _masked_edges(g, ocean, 4.0 / (g.counts[g.src] * g.edge_km ** 2)) * (P["kappa_m2s"] * 1e-6) + A = (adv - dif) / lam + sparse.identity(g.n) + A = (sparse.diags(ocean.astype(float)) @ A + sparse.diags((~ocean).astype(float))).tocsr() + T, info = bicgstab_jacobi(A, T_eq, T_eq.copy(), 1.0 / A.diagonal(), 1e-9, 5000) + if info != 0: + raise ValueError(f"sst: solver did not converge (info={info})") + return T + + +def upwelling(g, ocean, wind, P, day_hours): + """Ekman pumping (m/yr, + up): w = ∇·M, M = τ×k/(ρf), |f| floored at `ekman_min_lat`. Transport pointing off a + coast leaves the coast cell (land carries none), so coastal upwelling needs no separate rule. Smoothed over + `upwell_coast_km`; 0 on land.""" + ocean = np.asarray(ocean, bool) + Om = omega(day_hours) + fmin = 2.0 * Om * np.sin(np.radians(P["ekman_min_lat"])) + f = np.where(g.lat >= 0, 1.0, -1.0) * np.maximum(np.abs(2.0 * Om * np.sin(np.radians(g.lat))), fmin) + tau = np.where(ocean[:, None], wind_stress(wind, P), 0.0) + M = np.cross(tau, g.xyz) / (RHO_W * f[:, None]) # m²/s + w = np.where(ocean, divergence(g, M) * 1e-3 * YEAR_S, 0.0) + return np.where(ocean, smooth_km(g, w, P["upwell_coast_km"]), 0.0) + + +def sst_front(g, ocean, sst_c): + """|∇SST| over the sea (°C per 100 km); edges to land count as flat, so coasts are no front.""" + sst_c = np.asarray(sst_c, dtype=np.float64) + both = ocean[g.src] & ocean[g.dst] + w = np.where(both, sst_c[g.dst] - sst_c[g.src], 0.0) / g.edge_km + grad = np.stack([np.bincount(g.src, weights=w * g.edge_tangents[:, c], minlength=g.n) for c in range(3)], axis=1) + return np.where(ocean, np.linalg.norm(grad, axis=1) * (2.0 / g.counts) * 100.0, 0.0) + + +def productivity(g, ocean, w, z, t_range, sst_c, P): + """0–1 sea productivity: upwelling (saturating), shallow shelf, winter mixing and SST fronts (where warm and cold + currents meet, e.g. a Brazil–Malvinas confluence; gradients under `front_min` °C/100 km add nothing), dimmed + toward the poles.""" + ocean = np.asarray(ocean, bool) + depth = np.maximum(-np.asarray(z, dtype=np.float64), 0.0) + x = np.maximum(w, 0.0) / P["upwell_ref_m_yr"] + shelf = np.clip((1000.0 - depth) / 800.0, 0.0, 1.0) + mix = np.clip(np.asarray(t_range) / 20.0, 0.0, 1.0) * np.clip((20.0 - np.asarray(sst_c)) / 20.0, 0.0, 1.0) + light = 0.3 + 0.7 * np.cos(np.radians(g.lat)) + xf = np.maximum(sst_front(g, ocean, sst_c) - P["front_min"], 0.0) / P["front_ref"] + p = (P["prod_upwell"] * x / (1.0 + x) + P["prod_shelf"] * shelf + P["prod_mix"] * mix + + P["prod_front"] * xf / (1.0 + xf)) * light + return np.where(ocean, np.clip(p, 0.0, 1.0), 0.0) |
