worldgen

git clone https://git.godosa.eu/worldgen

master

raw · 8327 bytes

"""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)