raw · 8327 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 | """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) |