worldgen

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

master

raw · 12550 bytes

"""Stage `climate`: seasonal insolation → temperature, 3-cell winds + monsoons, moisture transport → rain."""
from __future__ import annotations

import numpy as np
from scipy import sparse

from . import ocean as OC
from .config import params
from .graph import bicgstab_jacobi, distance_to, gradient, nearest_source, pmap, smooth_km
from .grid import rowdot_at
from .sphere import east_north, latlon_to_xyz, rotate_about, tangent_dir

S0 = 1361.0
SEA_FREEZE_C = -1.8          # the exported SST never goes below sea water's freezing point (ice-covered sea)
SEASONS = ("jun", "dec", "eq")
DEFAULTS = {
    "t_a": -55.2, "t_b": 0.3206, "t_c": -2.974e-4, "land_seasonal": 0.45, "land_summer": 0.9, "continentality_summer_max": 1.5, "ocean_seasonal": 0.15,
    "continentality_km": 1500.0, "continentality_max": 1.8, "lapse_c_per_km": 6.5,
    "current_c": 4.0, "current_reach_km": 600.0, "current_leak_km": 150.0, "heat_transport_km": 300.0,
    "hadley_edge_deg": 20.0, "ferrel_edge_deg": 55.0, "itcz_shift_deg": 8.0,
    "trade_u": -6.0, "trade_v": 2.0, "westerly_u": 8.0, "westerly_v": 1.0, "polar_u": -4.0, "polar_v": 1.0,
    "monsoon_k": 1.0, "monsoon_length_km": 1500.0, "monsoon_speed_scale": 6000.0,
    "coriolis_min_deg": 20.0, "coriolis_span_deg": 50.0,
    "base_rate": 0.25, "conv_rate": 2.0, "front_rate": 0.8, "front_lat_deg": 40.0, "front_width_deg": 10.0, "itcz_width_deg": 8.0, "oro_rate": 20.0, "subsidence": 0.2,
    "min_rate": 0.05, "cc_per_c": 0.07, "recycle": 0.83, "eddy_k_m2s": 2.2e6,
    "eddy_wind_ms": 8.0,
    "global_mean_mm": 1000.0,
    "lock": False, "lock_at": [0.0, 0.0], "lock_day_c": 120.0, "lock_night_c": -200.0, "lock_wind_ms": 10.0,
    "lock_melt_c": 0.0, "lock_melt_width_c": 25.0,
}


def t_of_q(q, P):
    """Radiative-equilibrium-like surface temperature (°C) from insolation (W/m²); quadratic fit to Earth-like
    zonal means for a 20° tilt: equator 27, 45° ≈ 15, 60° ≈ 3, pole ≈ −22 (concave: damps polar-day summers)."""
    return P["t_a"] + P["t_b"] * q + P["t_c"] * q * q


def declinations(tilt):
    return {"jun": tilt, "dec": -tilt, "eq": 0.0}


def insolation(lat, decl):
    """Daily-mean top-of-atmosphere insolation (W/m²)."""
    phi = np.radians(np.clip(lat, -89.9999, 89.9999))
    d = np.radians(decl)
    h0 = np.arccos(np.clip(-np.tan(phi) * np.tan(d), -1.0, 1.0))
    return S0 / np.pi * (h0 * np.sin(phi) * np.sin(d) + np.cos(phi) * np.cos(d) * np.sin(h0))


def zonal_mean(g, f, bin_deg=2.0):
    b = np.floor((g.lat + 90.0) / bin_deg).astype(np.int64)
    s = np.bincount(b, weights=f * g.area_km2)
    w = np.bincount(b, weights=g.area_km2)
    return (s / np.maximum(w, 1e-12))[b]


def current_anomaly(g, land, P):
    """Subtropical gyres: cold water off west coasts, warm off east coasts; leaks onto coastal land."""
    if not land.any():
        return np.zeros(g.n)
    d, src = nearest_source(g, np.flatnonzero(land))
    e, _ = east_north(g.xyz)
    east_comp = np.sum(tangent_dir(g.xyz, g.xyz[np.maximum(src, 0)]) * e, axis=1)
    band = np.sin(np.radians((np.clip(np.abs(g.lat), 10.0, 50.0) - 10.0) * 4.5))
    a = -P["current_c"] * np.sign(east_comp) * band * np.exp(-d / P["current_reach_km"])
    a[land] = 0.0
    return smooth_km(g, a, P["current_leak_km"])


def temperatures(g, z, land, P, tilt, cur=None):
    decl = declinations(tilt)
    Q = {s: insolation(g.lat, d) for s, d in decl.items()}
    q_ann = (Q["jun"] + Q["dec"] + 2 * Q["eq"]) / 4
    t_ann = t_of_q(q_ann, P)
    dist_ocean = distance_to(g, ~land) if (~land).any() else np.full(g.n, 1e4)
    cont = np.clip(1.0 + dist_ocean / P["continentality_km"], 1.0, P["continentality_max"])
    cur = current_anomaly(g, land, P) if cur is None else cur
    lapse = -P["lapse_c_per_km"] * np.maximum(z, 0.0) / 1000.0
    def season(s):
        raw = t_of_q(Q[s], P)
        land_resp = np.where(raw > t_ann, P["land_summer"] * np.minimum(cont, P["continentality_summer_max"]),
                             P["land_seasonal"] * cont)       # land heats faster in summer (dry, low heat capacity)
        resp = np.where(land, land_resp, P["ocean_seasonal"])
        return smooth_km(g, t_ann + resp * (raw - t_ann) + cur, P["heat_transport_km"]) + lapse
    return dict(zip(SEASONS, pmap(season, SEASONS))), dist_ocean


def biotemperature(tmean, trange, n=12):
    ph = np.linspace(0.0, 2 * np.pi, n, endpoint=False)
    t = np.asarray(tmean)[:, None] + (np.asarray(trange)[:, None] / 2) * np.sin(ph)[None, :]
    return np.clip(t, 0.0, 30.0).mean(axis=1)


def band_winds(g, itcz_lat, P):
    """3-cell surface winds (m/s) relative to the thermal equator."""
    phi = g.lat - itcz_lat
    a = np.abs(phi)
    sgn = np.where(phi >= 0, 1.0, -1.0)
    s1 = 0.5 * (1 + np.tanh((a - P["hadley_edge_deg"]) / 3.0))
    s2 = 0.5 * (1 + np.tanh((a - P["ferrel_edge_deg"]) / 4.0))
    u = (1 - s1) * P["trade_u"] + (s1 - s2) * P["westerly_u"] + s2 * P["polar_u"]
    v = sgn * (-(1 - s1) * P["trade_v"] + (s1 - s2) * P["westerly_v"] - s2 * P["polar_v"])
    e, n = east_north(g.xyz)
    return u[:, None] * e + v[:, None] * n


def monsoon_winds(g, T_s, land, P):
    """Thermal lows over hot land / highs over cold land, flow deflected by Coriolis."""
    anom = np.where(land, T_s - zonal_mean(g, T_s), 0.0)
    press = -P["monsoon_k"] * smooth_km(g, anom, P["monsoon_length_km"])
    flow = -gradient(g, press)
    theta = np.radians(P["coriolis_min_deg"] + P["coriolis_span_deg"] * np.abs(np.sin(np.radians(g.lat))))
    return rotate_about(g.xyz, flow, -np.sign(g.lat) * theta) * P["monsoon_speed_scale"]


def _rate(per_1000km, spacing_km):
    return 1.0 - np.exp(-np.maximum(per_1000km, 0.0) * spacing_km / 1000.0)


def eddy_mixing(g, P):
    """kappa · (nbr − I): the per-step eddy mixing with the neighbours — the same for every season (build once)."""
    kappa = P["eddy_k_m2s"] / (P["eddy_wind_ms"] * g.spacing_km * 1000.0)
    nbr = sparse.csr_matrix((1.0 / g.counts[g.src], (g.src, g.dst)), shape=(g.n, g.n))
    return kappa * (nbr - sparse.identity(g.n, format="csr"))


def precipitation(g, wind, T, z, land, itcz_lat, P, mixing=None):
    """Steady-state moisture transport along the wind on the cell graph; returns rain (relative units).
    mixing: eddy_mixing(g, P), when several seasons share it."""
    t = g.edge_tangents
    out = np.maximum(rowdot_at(wind, g.src, t), 0.0)
    tot = np.bincount(g.src, weights=out, minlength=g.n)
    frac = np.where(tot[g.src] > 0, out / np.maximum(tot[g.src], 1e-12), 0.0)
    Tm = sparse.csr_matrix((frac, (g.dst, g.src)), shape=(g.n, g.n))
    stay = (tot <= 0).astype(np.float64)
    upslope = np.maximum(np.sum(wind * gradient(g, np.maximum(z, 0.0) / 1000.0), axis=1), 0.0)
    conv = P["conv_rate"] * np.exp(-((g.lat - itcz_lat) / P["itcz_width_deg"]) ** 2) * np.clip((T - 10.0) / 20.0, 0, 1)
    subs = P["subsidence"] * np.exp(-((np.abs(g.lat - itcz_lat) - P["hadley_edge_deg"]) / 6.0) ** 2)
    front = P["front_rate"] * np.exp(-((np.abs(g.lat - itcz_lat) - P["front_lat_deg"]) / P["front_width_deg"]) ** 2)
    per = np.maximum(P["base_rate"] + conv + front + P["oro_rate"] * upslope - subs, P["min_rate"])
    r = _rate(per, g.spacing_km)
    evap = np.where(land, 0.0, np.exp(P["cc_per_c"] * (np.clip(T, -2.0, 35.0) - 25.0)))
    # per advection step (one cell, time h/U): rain out, move downwind, eddy-mix with neighbours
    mix = eddy_mixing(g, P) if mixing is None else mixing
    eye = sparse.identity(g.n, format="csr")
    keep = sparse.diags(1.0 - r)
    recyc = sparse.diags(np.where(land, P["recycle"] * r, 0.0))   # land evapotranspiration returns rain
    system = (eye - (Tm @ keep + sparse.diags(stay) @ keep) - mix - recyc).tocsr()
    W, info = bicgstab_jacobi(system, evap, evap / np.maximum(r, 1e-6), 1.0 / system.diagonal(), 1e-7, 5000)
    if info != 0:
        raise ValueError(f"precipitation: moisture solve did not converge (info={info})")
    return r * np.maximum(W, 0.0)


def run_locked(ctx, g, z, land, P) -> dict:
    """One face always to the sun: temperature by sun angle, no seasons; surface wind from night to the sun point."""
    sub = latlon_to_xyz(*P["lock_at"])
    mu = np.maximum(g.xyz @ sub, 0.0)
    t = P["lock_night_c"] + (P["lock_day_c"] - P["lock_night_c"]) * mu ** 0.25
    lapse = -P["lapse_c_per_km"] * np.maximum(z, 0.0) / 1000.0
    T = smooth_km(g, t, P["heat_transport_km"]) + lapse
    dist_ocean = distance_to(g, ~land) if (~land).any() else np.full(g.n, 1e4)
    wind = P["lock_wind_ms"] * tangent_dir(g.xyz, np.broadcast_to(sub, g.xyz.shape))
    rain = np.exp(-((T - P["lock_melt_c"]) / P["lock_melt_width_c"]) ** 2)   # meltwater and frost in the twilight ring
    k = P["global_mean_mm"] / max(np.sum(rain * g.area_km2) / g.area_km2.sum(), 1e-12)
    out = {}
    for s in SEASONS:
        out[f"wind_{s}"] = wind.astype(np.float32)
        out[f"P_{s}"] = rain * k
        out[f"T_{s}"] = T
    out["P_ann"] = rain * k
    out["T_mean"] = T
    out["T_range"] = np.zeros(g.n)
    out["T_min"] = T
    out["biotemp"] = biotemperature(T, out["T_range"])
    out["PET"] = 58.93 * out["biotemp"]
    out["dist_ocean_km"] = dist_ocean
    return out


def _still_ocean(g, t_mean):
    """No circulation (ocean disabled, locked world): zero currents/upwelling/productivity, SST = T_mean."""
    z = np.zeros(g.n, np.float32)
    return {"current": np.zeros((g.n, 3), np.float32), "current_speed": z, "sst": np.asarray(t_mean, np.float32),
            "upwelling": z.copy(), "productivity": z.copy()}


def run(ctx) -> dict:
    g = ctx.grid
    P = params(ctx.cfg, "climate", DEFAULTS)
    tilt = float(ctx.cfg["planet"]["tilt_deg"])
    (z,) = ctx.need("elevation_eroded_m")
    z = z.astype(np.float64)
    water = ctx.data.get("open_water", ctx.data.get("ocean"))            # big inland basins are water to the air
    land = ~np.asarray(water) if water is not None else z > 0
    O = params(ctx.cfg, "ocean", OC.DEFAULTS)
    if P["lock"]:
        out = run_locked(ctx, g, z, land, P)
        out.update(_still_ocean(g, out["T_mean"]))
        return out
    sea = ~land
    coupled = bool(O["enabled"]) and bool(sea.any())
    T, dist_ocean = temperatures(g, z, land, P, tilt, cur=np.zeros(g.n) if coupled else None)
    decl = declinations(tilt)
    def season_wind(s):
        itcz = P["itcz_shift_deg"] * decl[s] / max(tilt, 1e-9)
        wind = band_winds(g, itcz, P)
        return wind + monsoon_winds(g, T[s], land, P) if s != "eq" else wind
    winds = dict(zip(SEASONS, pmap(season_wind, SEASONS)))
    if coupled:                     # one pass: winds from current-free temperatures, then currents carry heat
        day = float(ctx.cfg["planet"]["day_hours"])
        w_ann = (winds["jun"] + winds["dec"] + 2 * winds["eq"]) / 4
        u = OC.currents(g, sea, w_ann, O, day)
        T_eq = (T["jun"] + T["dec"] + 2 * T["eq"]) / 4
        T_s = OC.sst(g, sea, u, T_eq, O)
        cur = smooth_km(g, np.where(sea, T_s - T_eq, 0.0), P["current_leak_km"])
        T, _ = temperatures(g, z, land, P, tilt, cur=cur)
    out = {}
    mixing = eddy_mixing(g, P)
    rain = dict(zip(SEASONS, pmap(lambda s: precipitation(g, winds[s], T[s], z, land,
                                                           P["itcz_shift_deg"] * decl[s] / max(tilt, 1e-9), P, mixing),
                                  SEASONS)))
    del mixing
    for s in SEASONS:
        out[f"wind_{s}"] = winds[s].astype(np.float32)
    ann = (rain["jun"] + rain["dec"] + 2 * rain["eq"]) / 4
    k = P["global_mean_mm"] / max(np.sum(ann * g.area_km2) / g.area_km2.sum(), 1e-12)
    for s in SEASONS:
        out[f"P_{s}"] = rain[s] * k
        out[f"T_{s}"] = T[s]
    out["P_ann"] = ann * k
    out["T_mean"] = (T["jun"] + T["dec"] + 2 * T["eq"]) / 4
    out["T_range"] = np.abs(T["jun"] - T["dec"])
    out["T_min"] = np.minimum(T["jun"], T["dec"])
    out["biotemp"] = biotemperature(out["T_mean"], out["T_range"])
    out["PET"] = 58.93 * out["biotemp"]
    out["dist_ocean_km"] = dist_ocean
    if coupled:
        sst_c = np.where(sea, np.maximum(T_s, SEA_FREEZE_C), out["T_mean"])
        w_up = OC.upwelling(g, sea, w_ann, O, day)
        out.update({"current": u.astype(np.float32),
                    "current_speed": np.linalg.norm(u, axis=1).astype(np.float32),
                    "sst": sst_c.astype(np.float32),
                    "upwelling": w_up.astype(np.float32),
                    "productivity": OC.productivity(g, sea, w_up, z, out["T_range"], sst_c, O).astype(np.float32)})
    else:
        out.update(_still_ocean(g, out["T_mean"]))
    return out