pluvium 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pluvium/__init__.py +15 -0
- pluvium/advection.py +70 -0
- pluvium/atmos/__init__.py +33 -0
- pluvium/atmos/assemble.py +63 -0
- pluvium/atmos/cyclones.py +96 -0
- pluvium/atmos/diurnal.py +40 -0
- pluvium/atmos/moisture.py +66 -0
- pluvium/atmos/saharan.py +36 -0
- pluvium/atmos/sea_breeze.py +50 -0
- pluvium/atmos/thermo.py +128 -0
- pluvium/atmos/upslope.py +56 -0
- pluvium/atmos/waves.py +39 -0
- pluvium/bench.py +254 -0
- pluvium/benchmarks/__init__.py +3 -0
- pluvium/benchmarks/operational.py +879 -0
- pluvium/calibrate.py +147 -0
- pluvium/card.py +146 -0
- pluvium/chain.py +256 -0
- pluvium/cli.py +157 -0
- pluvium/convection.py +90 -0
- pluvium/dataset.py +424 -0
- pluvium/domain.py +510 -0
- pluvium/engine/__init__.py +28 -0
- pluvium/engine/experts.py +38 -0
- pluvium/engine/lead_film.py +53 -0
- pluvium/engine/mc_heads.py +31 -0
- pluvium/engine/refiner.py +71 -0
- pluvium/engine/temporal_mixer.py +34 -0
- pluvium/engine/terrain_patch.py +55 -0
- pluvium/features.py +304 -0
- pluvium/flood.py +180 -0
- pluvium/forecast.py +540 -0
- pluvium/guard/__init__.py +24 -0
- pluvium/guard/consistency.py +71 -0
- pluvium/guard/drift.py +58 -0
- pluvium/guard/fallback.py +67 -0
- pluvium/guard/freshness.py +47 -0
- pluvium/guard/inputs.py +112 -0
- pluvium/hydro/__init__.py +18 -0
- pluvium/hydro/antecedent.py +39 -0
- pluvium/hydro/hydrograph.py +56 -0
- pluvium/hydro/runoff.py +47 -0
- pluvium/insight/__init__.py +16 -0
- pluvium/insight/attribution.py +68 -0
- pluvium/insight/cases.py +42 -0
- pluvium/insight/errors.py +50 -0
- pluvium/losses.py +202 -0
- pluvium/model.py +591 -0
- pluvium/narrative.py +98 -0
- pluvium/online.py +242 -0
- pluvium/ops/__init__.py +14 -0
- pluvium/ops/api.py +93 -0
- pluvium/ops/loop.py +115 -0
- pluvium/physics.py +177 -0
- pluvium/prob/__init__.py +23 -0
- pluvium/prob/conformal.py +52 -0
- pluvium/prob/copula.py +74 -0
- pluvium/prob/diagnostics.py +57 -0
- pluvium/prob/extremes.py +91 -0
- pluvium/prob/scores.py +88 -0
- pluvium/products.py +192 -0
- pluvium/program.py +588 -0
- pluvium/render/__init__.py +16 -0
- pluvium/render/alerts.py +77 -0
- pluvium/render/bulletin.py +44 -0
- pluvium/render/maps.py +59 -0
- pluvium/schedule.py +67 -0
- pluvium/sources/__init__.py +3 -0
- pluvium/sources/baselines.py +216 -0
- pluvium/sources/cmorph.py +226 -0
- pluvium/sources/era5.py +215 -0
- pluvium/sources/glm.py +79 -0
- pluvium/sources/goes.py +157 -0
- pluvium/sources/metar.py +80 -0
- pluvium/sources/mrms.py +84 -0
- pluvium/sources/nwp.py +382 -0
- pluvium/sources/stations.py +204 -0
- pluvium/synthetic.py +206 -0
- pluvium/train.py +571 -0
- pluvium/training/__init__.py +20 -0
- pluvium/training/augment.py +53 -0
- pluvium/training/curriculum.py +58 -0
- pluvium/training/distill.py +38 -0
- pluvium/training/ema.py +50 -0
- pluvium/util.py +144 -0
- pluvium/verify.py +218 -0
- pluvium/weather.py +283 -0
- pluvium/zarrlite.py +166 -0
- pluvium-0.1.0.dist-info/METADATA +171 -0
- pluvium-0.1.0.dist-info/RECORD +93 -0
- pluvium-0.1.0.dist-info/WHEEL +4 -0
- pluvium-0.1.0.dist-info/entry_points.txt +2 -0
- pluvium-0.1.0.dist-info/licenses/LICENSE +203 -0
pluvium/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
Pluvium: hour-by-hour rain forecasts for every Caribbean town, on the NatureV1 / iHELIX engine.
|
|
4
|
+
|
|
5
|
+
``pluvium forecast`` makes the live forecast with a published model; ``pluvium program`` trains one on a single GPU.
|
|
6
|
+
See :mod:`pluvium.model` for the architecture and its experimental components.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
from .domain import Gazetteer, Geography, PointSet # noqa: E402
|
|
12
|
+
from .model import PluviumConfig, PluviumModel # noqa: E402
|
|
13
|
+
from .schedule import DEFAULT, Schedule # noqa: E402
|
|
14
|
+
|
|
15
|
+
__all__ = ["DEFAULT", "Gazetteer", "Geography", "PluviumConfig", "PluviumModel", "PointSet", "Schedule", "__version__"]
|
pluvium/advection.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
Rain already falling, carried forward (experimental as an input to a neural forecaster of this kind).
|
|
4
|
+
|
|
5
|
+
For the first hours of a forecast nothing beats extrapolation: the shower over the sea east of Port Antonio at
|
|
6
|
+
2 pm is over Port Antonio at 3 pm if the steering wind is 25 km/h from the east. Radar nowcasting systems (pySTEPS,
|
|
7
|
+
DGMR, NowcastNet's evolution network) are built on this. The Caribbean has little open radar, so here the rain field
|
|
8
|
+
of the last hour (CMORPH in training, GOES-East live) is advected semi-Lagrangian-wise along the steering wind (the
|
|
9
|
+
mean of the driving model's 850 and 700 hPa winds at the issue time): each point's value for forecast hour L is the
|
|
10
|
+
observed rain at the point the wind will carry to it in L + 1 hours. The network gets that as one more input per
|
|
11
|
+
point and hour for the first 12 hours, with a flag for whether the rain field was observed, and learns how fast
|
|
12
|
+
such rain decays or grows.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from .domain import KM_PER_DEG
|
|
20
|
+
|
|
21
|
+
HOURS = 12 # advected rain is given for forecast hours 0-11
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _xyz(lat, lon) -> np.ndarray:
|
|
25
|
+
la, lo = np.radians(np.asarray(lat, np.float64)), np.radians(np.asarray(lon, np.float64))
|
|
26
|
+
return np.stack([np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)], -1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Advector:
|
|
30
|
+
"""Observed rain at scattered points (``obs_lat``, ``obs_lon``) read at upstream positions."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, obs_lat: np.ndarray, obs_lon: np.ndarray, neighbours: int = 4, max_km: float = 45.0) -> None:
|
|
33
|
+
self.xyz = _xyz(obs_lat, obs_lon)
|
|
34
|
+
self.k, self.max_km = neighbours, max_km
|
|
35
|
+
|
|
36
|
+
def read(self, values: np.ndarray, lat: np.ndarray, lon: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
37
|
+
"""Inverse-distance mean of the ``k`` nearest observed values within ``max_km`` of each point, and whether
|
|
38
|
+
any was found."""
|
|
39
|
+
from scipy.spatial import cKDTree
|
|
40
|
+
|
|
41
|
+
ok = np.isfinite(values)
|
|
42
|
+
if not ok.any():
|
|
43
|
+
return np.zeros(np.shape(lat), np.float32), np.zeros(np.shape(lat), bool)
|
|
44
|
+
tree = cKDTree(self.xyz[ok])
|
|
45
|
+
dist, idx = tree.query(_xyz(lat, lon).reshape(-1, 3), k=min(self.k, int(ok.sum())))
|
|
46
|
+
dist, idx = np.atleast_2d(dist.T).T if dist.ndim == 1 else dist, np.atleast_2d(idx.T).T if idx.ndim == 1 else idx
|
|
47
|
+
km = dist * 6371.0
|
|
48
|
+
w = np.where(km <= self.max_km, 1.0 / (km + 2.0), 0.0)
|
|
49
|
+
vals = values[ok][idx]
|
|
50
|
+
found = w.sum(-1) > 0
|
|
51
|
+
out = (w * vals).sum(-1) / np.maximum(w.sum(-1), 1e-12)
|
|
52
|
+
return out.reshape(np.shape(lat)).astype(np.float32), found.reshape(np.shape(lat))
|
|
53
|
+
|
|
54
|
+
def extrapolate(self, values: np.ndarray, lat: np.ndarray, lon: np.ndarray, u: np.ndarray, v: np.ndarray,
|
|
55
|
+
leads: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
56
|
+
"""``(len(leads), N)`` advected rain (mm/h) and found-flags for forecast hours ``leads`` at points, with the
|
|
57
|
+
steering wind ``u``/``v`` (m/s, ``(N,)``)."""
|
|
58
|
+
lat, lon = np.asarray(lat, np.float64), np.asarray(lon, np.float64)
|
|
59
|
+
out = np.zeros((len(leads), lat.size), np.float32)
|
|
60
|
+
found = np.zeros((len(leads), lat.size), bool)
|
|
61
|
+
coslat = np.cos(np.radians(lat))
|
|
62
|
+
for i, lead in enumerate(leads):
|
|
63
|
+
dt = float(lead) + 1.0 # last observed hour's middle -> this hour's middle
|
|
64
|
+
up_lat = lat - v * 3.6 * dt / KM_PER_DEG
|
|
65
|
+
up_lon = lon - u * 3.6 * dt / (KM_PER_DEG * np.maximum(coslat, 0.2))
|
|
66
|
+
out[i], found[i] = self.read(values, up_lat, up_lon)
|
|
67
|
+
return out, found
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["Advector", "HOURS"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
Theoretical atmospheric physics computed from the driving fields and given to the network as inputs.
|
|
4
|
+
|
|
5
|
+
Each module targets a known weakness of data-driven rain forecasting in the tropics:
|
|
6
|
+
|
|
7
|
+
* :mod:`.thermo` -- exact moist thermodynamics (Bolton 1980) and parcel CAPE/CIN, instead of crude indices;
|
|
8
|
+
* :mod:`.moisture` -- column relative humidity and its exponential control of tropical rain (Bretherton et al.
|
|
9
|
+
2004), and the vertically integrated moisture-flux convergence;
|
|
10
|
+
* :mod:`.saharan` -- the Saharan air layer, which suppresses Caribbean summer showers;
|
|
11
|
+
* :mod:`.waves` -- tropical waves: trough axes at 700 hPa, their westward motion and arrival times;
|
|
12
|
+
* :mod:`.cyclones` -- tropical cyclones: vortex detection and a parametric rain field (R-CLIPER-style), because
|
|
13
|
+
cyclones are too rare in any training period for a network to learn their rain from data alone;
|
|
14
|
+
* :mod:`.sea_breeze` -- the sea-breeze front: when it reaches each inland point, from gravity-current theory;
|
|
15
|
+
* :mod:`.diurnal` -- the heating clock: insolation since sunrise, and the nocturnal maritime rain maximum;
|
|
16
|
+
* :mod:`.upslope` -- wind meeting terrain at every point: forced ascent at two scales, and blocking (Froude);
|
|
17
|
+
* :mod:`.assemble` -- all of it as the extra coarse channels and per-point inputs of version 3.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .assemble import CHANNELS as ATMOS_CHANNELS
|
|
21
|
+
from .assemble import POINT_FEATURES, atmos_channels, point_physics
|
|
22
|
+
from .cyclones import cyclone_features, find_cyclones, parametric_rain
|
|
23
|
+
from .diurnal import diurnal_features
|
|
24
|
+
from .moisture import column_relative_humidity, moisture_channels
|
|
25
|
+
from .saharan import saharan_index
|
|
26
|
+
from .sea_breeze import sea_breeze_arrival
|
|
27
|
+
from .thermo import cape_cin, lcl, mixing_ratio, saturation_vapour_pressure, theta_e_bolton
|
|
28
|
+
from .upslope import upslope_features
|
|
29
|
+
from .waves import wave_channels
|
|
30
|
+
|
|
31
|
+
__all__ = ["ATMOS_CHANNELS", "POINT_FEATURES", "atmos_channels", "cape_cin", "point_physics", "upslope_features", "column_relative_humidity", "cyclone_features", "diurnal_features",
|
|
32
|
+
"find_cyclones", "lcl", "mixing_ratio", "moisture_channels", "parametric_rain", "saharan_index",
|
|
33
|
+
"saturation_vapour_pressure", "sea_breeze_arrival", "theta_e_bolton", "wave_channels"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
Putting the atmospheric physics together: the extra coarse channels and the extra per-point inputs of version 3.
|
|
4
|
+
|
|
5
|
+
Coarse channels (on the 1-degree grid, per frame): CAPE, CIN and LCL height of an 850 hPa parcel; column relative
|
|
6
|
+
humidity and the Bretherton rain rate it implies; vertically integrated moisture-flux convergence; the Saharan air
|
|
7
|
+
layer index; tropical-wave trough strength and arrival time; parametric cyclone rain and closeness to a cyclone.
|
|
8
|
+
|
|
9
|
+
Per-point inputs (per frame): forced ascent at two terrain scales and the Froude number; the sea-breeze pulse and the
|
|
10
|
+
hours since the front passed; insolation since sunrise and the land and sea rain clocks.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .cyclones import cyclone_features
|
|
18
|
+
from .diurnal import diurnal_features
|
|
19
|
+
from .moisture import moisture_channels
|
|
20
|
+
from .saharan import saharan_index
|
|
21
|
+
from .sea_breeze import sea_breeze_arrival
|
|
22
|
+
from .thermo import cape_cin
|
|
23
|
+
from .upslope import upslope_features
|
|
24
|
+
from .waves import wave_channels
|
|
25
|
+
|
|
26
|
+
CHANNELS = ("cape", "cin", "lcl_height", "crh", "bretherton_rate", "vimfc", "saharan", "wave_trough", "wave_eta",
|
|
27
|
+
"tc_rain", "tc_distance")
|
|
28
|
+
POINT_FEATURES = ("upslope_5km", "upslope_20km", "froude", "sea_breeze_pulse", "sea_breeze_since", "insolation",
|
|
29
|
+
"land_clock", "sea_clock")
|
|
30
|
+
NEEDS = tuple(f"{v}{p}" for v in ("t", "q", "u", "v") for p in (850, 700, 500, 200))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def atmos_channels(fields: dict, upper: dict, lat: np.ndarray, lon: np.ndarray, hours: np.ndarray) -> np.ndarray:
|
|
34
|
+
"""``(F, len(CHANNELS), H, W)`` from raw fields and upper-air fields already on the hourly grid."""
|
|
35
|
+
t = {p: upper[f"t{p}"] for p in (850, 700, 500, 200)}
|
|
36
|
+
q = {p: upper[f"q{p}"] for p in (850, 700, 500, 200)}
|
|
37
|
+
cape, cin, lcl_m = cape_cin(t, q)
|
|
38
|
+
moist = moisture_channels(upper, lat, lon)
|
|
39
|
+
sal = saharan_index(upper, hours)
|
|
40
|
+
waves = wave_channels(upper, lat, lon, hours)
|
|
41
|
+
tc = cyclone_features(fields, upper, lat, lon)
|
|
42
|
+
out = np.concatenate([np.stack([np.log1p(cape) / 7.0, cin / 100.0, lcl_m / 1000.0], 1), moist, sal[:, None],
|
|
43
|
+
waves, tc], 1)
|
|
44
|
+
return np.nan_to_num(out).astype(np.float32)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def point_physics(terrain, statics: np.ndarray, lat: np.ndarray, lon: np.ndarray, u10: np.ndarray, v10: np.ndarray,
|
|
48
|
+
hours: np.ndarray, kind: np.ndarray | None = None) -> np.ndarray:
|
|
49
|
+
"""``(F, N, len(POINT_FEATURES))`` from the points' static descriptors and the frame winds at them."""
|
|
50
|
+
from ..domain import STATIC_FEATURES
|
|
51
|
+
|
|
52
|
+
col = {name: k for k, name in enumerate(STATIC_FEATURES)}
|
|
53
|
+
relief = statics[:, col["elev_max"]] * 1000.0
|
|
54
|
+
up = upslope_features(terrain, lat, lon, u10, v10, relief)
|
|
55
|
+
land = statics[:, col["land_5km"]] > 0.5 if kind is None else np.asarray(kind) == 0
|
|
56
|
+
coast_km = statics[:, col["coast_km"]] * 100.0
|
|
57
|
+
pulse, since = sea_breeze_arrival(coast_km, statics[:, col["sea_dir_x"]], statics[:, col["sea_dir_y"]], u10, v10,
|
|
58
|
+
lon, hours, land)
|
|
59
|
+
di = diurnal_features(lat, lon, hours, statics[:, col["land_15km"]])
|
|
60
|
+
return np.concatenate([up, pulse[..., None], (since / 12.0)[..., None], di], -1).astype(np.float32)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["CHANNELS", "NEEDS", "POINT_FEATURES", "atmos_channels", "point_physics"]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
Tropical cyclones: too rare to learn from data, too important to miss.
|
|
4
|
+
|
|
5
|
+
Even six training years hold only a handful of hurricanes passing near any one island, so a network cannot learn
|
|
6
|
+
from data alone how rain is arranged around a storm. Theory and climatology give the shape:
|
|
7
|
+
|
|
8
|
+
* **finding the storm** -- a minimum of sea-level pressure below 1006 hPa with strong cyclonic 850 hPa vorticity;
|
|
9
|
+
its strength from the pressure deficit (the Atkinson-Holliday wind-pressure relation, ``V = 6.7 (1010 - p)^0.644``
|
|
10
|
+
knots);
|
|
11
|
+
* **its rain** -- an R-CLIPER-shaped radial profile (Marks and DeMaria 2003; Tuleya et al. 2007): rain rises
|
|
12
|
+
linearly from the centre to a maximum at the radius of maximum rain, then decays exponentially, the maximum and the
|
|
13
|
+
radii growing with intensity (azimuthal-mean peaks of about 5-15 mm/h, Lonfat et al. 2004);
|
|
14
|
+
* **its asymmetry** -- with vertical wind shear the rain concentrates down-shear and to the left (Chen et al. 2006),
|
|
15
|
+
a cosine weighting by the angle to the down-shear-left direction.
|
|
16
|
+
|
|
17
|
+
The parametric field and the distance to the nearest storm are inputs; the network learns how much to trust them.
|
|
18
|
+
The coefficients here are chosen to match published magnitudes; they are a prior, not a fitted model.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from ..features import _grad
|
|
26
|
+
from ..util import great_circle_km
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_cyclones(msl: np.ndarray, u850: np.ndarray, v850: np.ndarray, lat: np.ndarray, lon: np.ndarray,
|
|
30
|
+
p_max_hpa: float = 1006.0, vort_min: float = 4.0) -> list[list[tuple]]:
|
|
31
|
+
"""Per frame, ``[(lat, lon, p_min hPa, v_max m/s), ...]`` of cyclone centres on the grid."""
|
|
32
|
+
from scipy import ndimage
|
|
33
|
+
|
|
34
|
+
vx, _ = _grad(v850, lat, lon)
|
|
35
|
+
_, uy = _grad(u850, lat, lon)
|
|
36
|
+
vort = vx - uy # 1e-5 per second
|
|
37
|
+
out = []
|
|
38
|
+
for f in range(msl.shape[0]):
|
|
39
|
+
p = msl[f] / 100.0
|
|
40
|
+
minima = (p == ndimage.minimum_filter(p, size=5, mode="nearest")) & (p < p_max_hpa)
|
|
41
|
+
found = []
|
|
42
|
+
for i, j in zip(*np.nonzero(minima)):
|
|
43
|
+
if ndimage.maximum_filter(vort[f], size=3)[i, j] < vort_min:
|
|
44
|
+
continue
|
|
45
|
+
v_kt = 6.7 * max(1010.0 - p[i, j], 0.0) ** 0.644
|
|
46
|
+
found.append((float(lat[i]), float(lon[j]), float(p[i, j]), v_kt * 0.5144))
|
|
47
|
+
out.append(found)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parametric_rain(dist_km: np.ndarray, vmax: float, shear_u: float = 0.0, shear_v: float = 0.0,
|
|
52
|
+
bearing: np.ndarray | None = None) -> np.ndarray:
|
|
53
|
+
"""Rain rate (mm/h) at distances from a storm of maximum wind ``vmax`` (m/s), with shear asymmetry."""
|
|
54
|
+
u = np.clip(vmax / 33.0, 0.3, 2.5) # 1 = a minimal hurricane
|
|
55
|
+
t_max = 4.0 + 5.0 * u # mm/h at the radius of maximum rain
|
|
56
|
+
r_m = np.clip(70.0 - 15.0 * u, 25.0, 70.0) # km
|
|
57
|
+
r_e = 120.0 + 60.0 * u # e-folding km
|
|
58
|
+
d = np.asarray(dist_km, np.float64)
|
|
59
|
+
inner = 0.3 * t_max + 0.7 * t_max * d / r_m
|
|
60
|
+
rate = np.where(d < r_m, inner, t_max * np.exp(-(d - r_m) / r_e))
|
|
61
|
+
if bearing is not None:
|
|
62
|
+
shear = np.hypot(shear_u, shear_v)
|
|
63
|
+
if shear > 1.0:
|
|
64
|
+
down = np.degrees(np.arctan2(shear_u, shear_v)) # direction the shear points to
|
|
65
|
+
left = (down - 45.0) % 360.0 # down-shear-left
|
|
66
|
+
a = np.clip(shear / 10.0, 0, 1) * 0.6
|
|
67
|
+
rate = rate * (1 + a * np.cos(np.radians(bearing - left)))
|
|
68
|
+
return np.clip(rate, 0, 60).astype(np.float32)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _bearing(lat0, lon0, lat, lon):
|
|
72
|
+
la0, la = np.radians(lat0), np.radians(lat)
|
|
73
|
+
dlon = np.radians(lon - lon0)
|
|
74
|
+
x = np.sin(dlon) * np.cos(la)
|
|
75
|
+
y = np.cos(la0) * np.sin(la) - np.sin(la0) * np.cos(la) * np.cos(dlon)
|
|
76
|
+
return (np.degrees(np.arctan2(x, y)) + 360.0) % 360.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cyclone_features(fields: dict, upper: dict, lat: np.ndarray, lon: np.ndarray) -> np.ndarray:
|
|
80
|
+
"""``(F, 2, H, W)``: parametric storm rain (log1p mm/h) and closeness to the nearest storm (exp(-d / 300 km))."""
|
|
81
|
+
la, lo = np.meshgrid(lat, lon, indexing="ij")
|
|
82
|
+
storms = find_cyclones(fields["msl"], upper["u850"], upper["v850"], lat, lon)
|
|
83
|
+
f = fields["msl"].shape[0]
|
|
84
|
+
rain = np.zeros((f,) + la.shape, np.float32)
|
|
85
|
+
close = np.zeros((f,) + la.shape, np.float32)
|
|
86
|
+
for k, found in enumerate(storms):
|
|
87
|
+
for s_lat, s_lon, _, vmax in found:
|
|
88
|
+
d = great_circle_km(la, lo, s_lat, s_lon)
|
|
89
|
+
su = float(np.nanmean(upper["u200"][k] - upper["u850"][k]))
|
|
90
|
+
sv = float(np.nanmean(upper["v200"][k] - upper["v850"][k]))
|
|
91
|
+
rain[k] = np.maximum(rain[k], parametric_rain(d, vmax, su, sv, _bearing(s_lat, s_lon, la, lo)))
|
|
92
|
+
close[k] = np.maximum(close[k], np.exp(-d / 300.0))
|
|
93
|
+
return np.stack([np.log1p(rain), close], 1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
__all__ = ["cyclone_features", "find_cyclones", "parametric_rain"]
|
pluvium/atmos/diurnal.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
The daily clock of tropical rain, written down instead of learned.
|
|
4
|
+
|
|
5
|
+
Global AI models are known to put the tropical daily rain maximum at the wrong hour (GraphCast concentrates it too
|
|
6
|
+
close to local noon). Two physical clocks set it:
|
|
7
|
+
|
|
8
|
+
* over **land**, rain follows heating: storms need several hours of sunshine to build the boundary layer, so the
|
|
9
|
+
maximum is mid to late afternoon. The integral of insolation since sunrise (normalised by its daily total) is the
|
|
10
|
+
"fuel gauge";
|
|
11
|
+
* over the **sea**, rain peaks before dawn: the cloud tops cool by radiation overnight while the sea stays warm, so
|
|
12
|
+
the column destabilises at night (the nocturnal maritime maximum), strongest near 05-07 local time.
|
|
13
|
+
|
|
14
|
+
Inputs per point and frame: the insolation fraction since sunrise, hours to sunset, and the two clocks weighted by
|
|
15
|
+
how much land surrounds the point.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from ..features import cos_zenith, local_solar_hour
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def diurnal_features(lat: np.ndarray, lon: np.ndarray, hours: np.ndarray, land_fraction: np.ndarray) -> np.ndarray:
|
|
26
|
+
"""``(F, N, 3)``: insolation since sunrise (0-1), the land clock and the sea clock (each -1..1, land-weighted)."""
|
|
27
|
+
hours = np.asarray(hours, np.float64)
|
|
28
|
+
lst = local_solar_hour(lon[None, :], hours[:, None])
|
|
29
|
+
# insolation since local sunrise, integrated over the preceding hours (cos zenith is clipped at night)
|
|
30
|
+
steps = np.arange(0, 13)
|
|
31
|
+
past = hours[:, None] - steps[None, :] # (F, 13)
|
|
32
|
+
cz = cos_zenith(lat[None, None, :], lon[None, None, :], past[..., None]) # (F, 13, N)
|
|
33
|
+
so_far = cz.sum(1) / 7.6 # about 1 by sunset in the Caribbean (sum of cos z)
|
|
34
|
+
land = np.clip(np.asarray(land_fraction, np.float64), 0, 1)[None]
|
|
35
|
+
land_clock = np.cos(2 * np.pi * (lst - 15.5) / 24.0)
|
|
36
|
+
sea_clock = np.cos(2 * np.pi * (lst - 5.5) / 24.0)
|
|
37
|
+
return np.stack([np.clip(so_far, 0, 1.5), land_clock * land, sea_clock * (1 - land)], -1).astype(np.float32)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["diurnal_features"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
The column's water, and the most robust law of tropical rain.
|
|
4
|
+
|
|
5
|
+
Over tropical oceans, rain rate rises roughly *exponentially* with column relative humidity -- the precipitable
|
|
6
|
+
water divided by its saturated value (Bretherton, Peters and Back 2004: ``P = exp(a (r - b))`` with ``a = 15.6``,
|
|
7
|
+
``b = 0.603``, in mm/day). A coarse driving model can put the right amount of water in the column and still rain
|
|
8
|
+
in the wrong place; giving the network this law as an input lets it place rain where the column is near saturation.
|
|
9
|
+
|
|
10
|
+
Also here: the vertically integrated moisture-flux convergence from the 850/700/500 hPa winds and humidities (where
|
|
11
|
+
water is being imported faster than it rains out), and the precipitation efficiency the driving model implies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .thermo import saturation_vapour_pressure
|
|
19
|
+
|
|
20
|
+
G = 9.80665
|
|
21
|
+
#: layer thicknesses (hPa) the three levels stand for in column integrals
|
|
22
|
+
WEIGHTS = {850: 225.0, 700: 175.0, 500: 250.0}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def saturated_q(t_k: np.ndarray, p_hpa: float) -> np.ndarray:
|
|
26
|
+
es = saturation_vapour_pressure(t_k)
|
|
27
|
+
return 0.622 * es / np.maximum(p_hpa - 0.378 * es, 1.0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def column_relative_humidity(t: dict, q: dict) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
31
|
+
"""``(precipitable water mm, saturated precipitable water mm, their ratio)`` from 850/700/500 hPa."""
|
|
32
|
+
pw = sum(WEIGHTS[p] * 100.0 * np.clip(q[p], 0, 0.04) for p in WEIGHTS) / G
|
|
33
|
+
pws = sum(WEIGHTS[p] * 100.0 * saturated_q(t[p], float(p)) for p in WEIGHTS) / G
|
|
34
|
+
return pw, pws, np.clip(pw / np.maximum(pws, 1e-3), 0, 1.2)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def bretherton_rate(crh: np.ndarray) -> np.ndarray:
|
|
38
|
+
"""Expected rain (mm/h) at a column relative humidity (Bretherton et al. 2004, converted from mm/day)."""
|
|
39
|
+
return np.exp(15.6 * (np.asarray(crh) - 0.603)) / 24.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def vimfc(u: dict, v: dict, q: dict, lat: np.ndarray, lon: np.ndarray) -> np.ndarray:
|
|
43
|
+
"""Vertically integrated moisture-flux convergence (mm/day) from the three lower levels."""
|
|
44
|
+
from ..features import _grad
|
|
45
|
+
|
|
46
|
+
total = 0.0
|
|
47
|
+
for p, w in WEIGHTS.items():
|
|
48
|
+
qu, qv = q[p] * u[p], q[p] * v[p]
|
|
49
|
+
gx, _ = _grad(qu, lat, lon)
|
|
50
|
+
_, gy = _grad(qv, lat, lon)
|
|
51
|
+
total = total - (gx + gy) * 1e-5 * w * 100.0 / G # per 100 km -> per metre; kg m-2 s-1
|
|
52
|
+
return total * 86400.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def moisture_channels(upper: dict, lat: np.ndarray, lon: np.ndarray) -> np.ndarray:
|
|
56
|
+
"""``(F, 3, H, W)``: column relative humidity, the Bretherton rain rate (log1p), and VIMFC (normalised)."""
|
|
57
|
+
t = {p: upper[f"t{p}"] for p in WEIGHTS}
|
|
58
|
+
q = {p: upper[f"q{p}"] for p in WEIGHTS}
|
|
59
|
+
u = {p: upper[f"u{p}"] for p in WEIGHTS}
|
|
60
|
+
v = {p: upper[f"v{p}"] for p in WEIGHTS}
|
|
61
|
+
_, _, crh = column_relative_humidity(t, q)
|
|
62
|
+
conv = vimfc(u, v, q, lat, lon)
|
|
63
|
+
return np.stack([(crh - 0.7) / 0.15, np.log1p(bretherton_rate(crh)), np.clip(conv / 20.0, -5, 5)], 1).astype(np.float32)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["bretherton_rate", "column_relative_humidity", "moisture_channels", "saturated_q", "vimfc"]
|
pluvium/atmos/saharan.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
The Saharan air layer (SAL): why a moist-looking summer forecast stays dry.
|
|
4
|
+
|
|
5
|
+
From June to September, outbreaks of hot, dry, dusty air from the Sahara cross the Atlantic between about 850 and
|
|
6
|
+
500 hPa (Dunion and Velden 2004). Over the Caribbean they cap the trade-wind cumulus, dry the mid-levels and come
|
|
7
|
+
with a mid-level easterly jet; showers that the low-level moisture would otherwise allow do not happen. Coarse
|
|
8
|
+
driving models carry the dryness but a network sees it only as one humidity among many. The index here combines the
|
|
9
|
+
three signatures (mid-level dryness, warmth of the 850-700 hPa layer relative to normal, and the easterly jet at 700
|
|
10
|
+
hPa), so "SAL overhead" is one clear input.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ..util import calendar
|
|
18
|
+
from .thermo import saturation_vapour_pressure, vapour_pressure_from_q
|
|
19
|
+
|
|
20
|
+
SEASON = (5, 6, 7, 8, 9, 10)
|
|
21
|
+
NORMAL_T700 = 283.5 # K, a typical Caribbean summer 700 hPa temperature
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def saharan_index(upper: dict, hours: np.ndarray) -> np.ndarray:
|
|
25
|
+
"""``(F, H, W)`` in [0, 1]: 0 no SAL, 1 a strong outbreak overhead (zero outside May-October)."""
|
|
26
|
+
rh700 = vapour_pressure_from_q(upper["q700"], 700.0) / saturation_vapour_pressure(upper["t700"])
|
|
27
|
+
rh500 = vapour_pressure_from_q(upper["q500"], 500.0) / saturation_vapour_pressure(upper["t500"])
|
|
28
|
+
dry = np.clip((0.55 - 0.5 * (rh700 + rh500)) / 0.25, 0, 1)
|
|
29
|
+
warm = np.clip((upper["t700"] - NORMAL_T700) / 2.0, 0, 1)
|
|
30
|
+
jet = np.clip((-upper["u700"] - 8.0) / 6.0, 0, 1)
|
|
31
|
+
month = calendar(np.asarray(hours).astype(np.int64))["month"]
|
|
32
|
+
season = np.isin(month, SEASON).astype(np.float64)[:, None, None]
|
|
33
|
+
return (dry * (0.5 + 0.5 * warm) * (0.7 + 0.3 * jet) * season).astype(np.float32)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["saharan_index"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
The sea-breeze front: where island afternoon storms are born.
|
|
4
|
+
|
|
5
|
+
On a sunny day the land heats, and by late morning cooler sea air pushes inland as a shallow gravity current. Its
|
|
6
|
+
leading edge -- the sea-breeze front -- lifts the moist air ahead of it; on islands the fronts from opposite coasts
|
|
7
|
+
meet inland in the afternoon, and that is where and when the storms go up. Gravity-current theory (Simpson 1994)
|
|
8
|
+
gives the front's speed, ``c = k sqrt(g' h)`` with ``g' = g dtheta / theta``, ``h`` about 500 m and ``k`` about 0.6;
|
|
9
|
+
the background wind adds its onshore component (an offshore wind stronger than ``c`` holds the front at the coast).
|
|
10
|
+
|
|
11
|
+
For every point and frame this module gives the hours since the front passed it (negative: still to come), from
|
|
12
|
+
the point's distance to the coast, the direction to the sea, the local wind, the time of day and a land-sea
|
|
13
|
+
temperature contrast that grows with the sunshine since sunrise. The feature the network sees is a pulse that peaks
|
|
14
|
+
as the front passes: the convergence line's moment.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ..features import local_solar_hour
|
|
22
|
+
|
|
23
|
+
G, H_M, K = 9.81, 500.0, 0.62
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sea_breeze_arrival(coast_km: np.ndarray, sea_x: np.ndarray, sea_y: np.ndarray, u10: np.ndarray, v10: np.ndarray,
|
|
27
|
+
lon: np.ndarray, hours: np.ndarray, land: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
28
|
+
"""
|
|
29
|
+
``(pulse (F, N), hours since passage (F, N))``. ``coast_km`` (N,) distance inland (positive over land),
|
|
30
|
+
``sea_x``/``sea_y`` (N,) unit vector towards the nearest sea, ``u10``/``v10`` (F, N), ``hours`` (F,) frame
|
|
31
|
+
hours (UTC since 1970), ``land`` (N,) bool.
|
|
32
|
+
"""
|
|
33
|
+
lst = local_solar_hour(lon[None, :], np.asarray(hours, np.float64)[:, None]) # (F, N)
|
|
34
|
+
since_sunrise = np.clip(lst - 6.0, 0, 12)
|
|
35
|
+
contrast = 4.0 * np.clip(np.sin(np.pi * np.clip(lst - 6.0, 0, 12) / 12.0), 0, 1) # K, peaks mid-afternoon
|
|
36
|
+
c = K * np.sqrt(G * np.maximum(contrast, 0.1) / 300.0 * H_M) # m/s
|
|
37
|
+
onshore = -(u10 * sea_x[None] + v10 * sea_y[None]) # wind from the sea
|
|
38
|
+
speed = np.maximum(c + onshore, 0.0)
|
|
39
|
+
onset = 3.0 # hours after sunrise
|
|
40
|
+
travelled_km = np.where(since_sunrise > onset, speed * 3.6 * (since_sunrise - onset), 0.0)
|
|
41
|
+
dist = np.maximum(np.asarray(coast_km, np.float64), 0.0)[None]
|
|
42
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
43
|
+
passed_h = np.where(speed > 0.3, (travelled_km - dist) / np.maximum(speed * 3.6, 1e-6), -12.0)
|
|
44
|
+
held = c + onshore < 0.3 # pinned at the coast
|
|
45
|
+
passed_h = np.where(held | ~land[None] | (lst < 6) | (lst > 19), -12.0, np.clip(passed_h, -12, 12))
|
|
46
|
+
pulse = np.exp(-0.5 * (passed_h / 1.5) ** 2) * (contrast > 1.0)
|
|
47
|
+
return pulse.astype(np.float32), passed_h.astype(np.float32)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["sea_breeze_arrival"]
|
pluvium/atmos/thermo.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Copyright 2026 Nathan. Apache-2.0.
|
|
2
|
+
"""
|
|
3
|
+
Moist thermodynamics, done properly.
|
|
4
|
+
|
|
5
|
+
Version 2 used the textbook shortcut ``theta-e = (T + L q / c_p)(1000/p)^0.286`` and a lifted index from 850 hPa.
|
|
6
|
+
Tropical convection lives in differences of a few kelvin, where the shortcut's error (1-3 K) is as large as the
|
|
7
|
+
signal. Here:
|
|
8
|
+
|
|
9
|
+
* saturation vapour pressure over water (Bolton 1980, eq. 10), mixing ratio, virtual temperature;
|
|
10
|
+
* equivalent potential temperature by Bolton's eq. 39 (accurate to about 0.02 K);
|
|
11
|
+
* the lifting condensation level by Bolton's eq. 21;
|
|
12
|
+
* a pseudo-adiabatic parcel ascent integrated in log-pressure, and the convective available potential energy and
|
|
13
|
+
convective inhibition of a parcel lifted from 850 hPa through the 850/700/500/200 hPa profile (log-pressure
|
|
14
|
+
interpolation of the environment). Four levels cannot resolve every inversion; the numbers are indices of the
|
|
15
|
+
column's readiness, not soundings, and are clipped to physically sensible ranges.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
RD, RV, CP, G, LV = 287.04, 461.5, 1005.7, 9.80665, 2.501e6
|
|
23
|
+
EPS = RD / RV
|
|
24
|
+
LEVELS = (850.0, 700.0, 500.0, 200.0)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def saturation_vapour_pressure(t_k: np.ndarray) -> np.ndarray:
|
|
28
|
+
"""hPa, over liquid water (Bolton 1980, eq. 10)."""
|
|
29
|
+
tc = np.asarray(t_k, np.float64) - 273.15
|
|
30
|
+
return 6.112 * np.exp(17.67 * tc / (tc + 243.5))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def mixing_ratio(e_hpa: np.ndarray, p_hpa) -> np.ndarray:
|
|
34
|
+
return EPS * e_hpa / np.maximum(np.asarray(p_hpa, np.float64) - e_hpa, 1e-3)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def vapour_pressure_from_q(q: np.ndarray, p_hpa) -> np.ndarray:
|
|
38
|
+
q = np.clip(np.asarray(q, np.float64), 1e-7, 0.05)
|
|
39
|
+
return q * p_hpa / (EPS + (1 - EPS) * q)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def dewpoint_k(e_hpa: np.ndarray) -> np.ndarray:
|
|
43
|
+
x = np.log(np.maximum(e_hpa, 1e-4) / 6.112)
|
|
44
|
+
return 243.5 * x / (17.67 - x) + 273.15
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def lcl(t_k: np.ndarray, td_k: np.ndarray) -> np.ndarray:
|
|
48
|
+
"""Temperature (K) at the lifting condensation level (Bolton 1980, eq. 15)."""
|
|
49
|
+
t, td = np.asarray(t_k, np.float64), np.asarray(td_k, np.float64)
|
|
50
|
+
return 1.0 / (1.0 / (td - 56.0) + np.log(t / td) / 800.0) + 56.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def theta_e_bolton(t_k: np.ndarray, q: np.ndarray, p_hpa) -> np.ndarray:
|
|
54
|
+
"""Equivalent potential temperature (K), Bolton 1980 eq. 39."""
|
|
55
|
+
t = np.asarray(t_k, np.float64)
|
|
56
|
+
e = vapour_pressure_from_q(q, p_hpa)
|
|
57
|
+
r = mixing_ratio(e, p_hpa) # kg/kg
|
|
58
|
+
tl = lcl(t, dewpoint_k(e))
|
|
59
|
+
theta_l = t * (1000.0 / (p_hpa - e)) ** 0.2854 * (t / tl) ** (0.28e-3 * r * 1000.0)
|
|
60
|
+
return theta_l * np.exp((3.036 / tl - 0.00178) * r * 1000.0 * (1 + 0.448e-3 * r * 1000.0))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _moist_lapse(t: np.ndarray, p_hpa: np.ndarray) -> np.ndarray:
|
|
64
|
+
"""dT/dlnp of a saturated parcel (pseudo-adiabatic), K per unit ln p."""
|
|
65
|
+
es = saturation_vapour_pressure(t)
|
|
66
|
+
rs = mixing_ratio(es, p_hpa)
|
|
67
|
+
num = (RD * t + LV * rs) / (CP + (LV**2 * rs * EPS) / (RD * t**2))
|
|
68
|
+
return num
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parcel_profile(t0: np.ndarray, q0: np.ndarray, p0: float, levels) -> np.ndarray:
|
|
72
|
+
"""Temperatures of a parcel lifted from ``p0`` (dry to its LCL, then moist), at ``levels`` (hPa, decreasing)."""
|
|
73
|
+
t0 = np.asarray(t0, np.float64)
|
|
74
|
+
e0 = vapour_pressure_from_q(q0, p0)
|
|
75
|
+
t_lcl = lcl(t0, dewpoint_k(e0))
|
|
76
|
+
p_lcl = p0 * (t_lcl / t0) ** (1 / 0.2854)
|
|
77
|
+
out = []
|
|
78
|
+
for p in levels:
|
|
79
|
+
dry = t0 * (p / p0) ** 0.2854
|
|
80
|
+
# moist ascent from the LCL to p in log-pressure steps
|
|
81
|
+
t = t_lcl.copy()
|
|
82
|
+
lp0, lp1 = np.log(np.minimum(p_lcl, p0)), np.log(p)
|
|
83
|
+
n = 12
|
|
84
|
+
for k in range(n):
|
|
85
|
+
lp = lp0 + (lp1 - lp0) * (k + 0.5) / n
|
|
86
|
+
t = t + _moist_lapse(t, np.exp(lp)) * (lp1 - lp0) / n
|
|
87
|
+
out.append(np.where(p >= p_lcl, dry, t))
|
|
88
|
+
return np.stack(out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cape_cin(t: dict, q: dict, origin: float = 850.0) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
92
|
+
"""
|
|
93
|
+
CAPE and CIN (J/kg) of a parcel from ``origin`` through the profile, and the LCL height (m), from temperatures
|
|
94
|
+
``t[p]`` and specific humidities ``q[p]`` at 850/700/500/200 hPa (arrays of any shape). The integral is a
|
|
95
|
+
trapezoid in ln p on a 16-level grid between 850 and 200 hPa (environment interpolated linearly in ln p).
|
|
96
|
+
"""
|
|
97
|
+
grid = np.exp(np.linspace(np.log(origin), np.log(200.0), 16))
|
|
98
|
+
lev = np.log(np.array(LEVELS))
|
|
99
|
+
env_t = np.stack([t[int(p)] for p in LEVELS]).astype(np.float64)
|
|
100
|
+
env_q = np.stack([q[int(p)] for p in LEVELS]).astype(np.float64)
|
|
101
|
+
|
|
102
|
+
def interp(field, lp):
|
|
103
|
+
k = np.clip(np.searchsorted(-lev, -lp) - 1, 0, len(LEVELS) - 2)
|
|
104
|
+
w = (lp - lev[k]) / (lev[k + 1] - lev[k])
|
|
105
|
+
return field[k] * (1 - w) + field[k + 1] * w
|
|
106
|
+
|
|
107
|
+
parcel = parcel_profile(t[int(origin)], q[int(origin)], origin, grid)
|
|
108
|
+
env = np.stack([interp(env_t, np.log(p)) for p in grid])
|
|
109
|
+
envq = np.stack([interp(env_q, np.log(p)) for p in grid])
|
|
110
|
+
tv_env = env * (1 + 0.61 * envq)
|
|
111
|
+
es = saturation_vapour_pressure(parcel)
|
|
112
|
+
qs = EPS * es / np.maximum(grid.reshape((-1,) + (1,) * (parcel.ndim - 1)) - (1 - EPS) * es, 1e-3)
|
|
113
|
+
tv_par = parcel * (1 + 0.61 * np.minimum(qs, q[int(origin)]))
|
|
114
|
+
buoy = RD * (tv_par - tv_env) / tv_env * tv_env # R_d (Tv_p - Tv_e)
|
|
115
|
+
dlnp = -np.diff(np.log(grid))
|
|
116
|
+
seg = 0.5 * (buoy[1:] + buoy[:-1]) * dlnp.reshape((-1,) + (1,) * (buoy.ndim - 1))
|
|
117
|
+
cape = np.clip(np.where(seg > 0, seg, 0).sum(0), 0, 6000)
|
|
118
|
+
# CIN: negative area below the first positive layer
|
|
119
|
+
positive = np.cumsum(seg > 0, axis=0) > 0
|
|
120
|
+
cin = np.clip(np.where((seg < 0) & ~positive, seg, 0).sum(0), -500, 0)
|
|
121
|
+
e0 = vapour_pressure_from_q(q[int(origin)], origin)
|
|
122
|
+
t_lcl = lcl(t[int(origin)], dewpoint_k(e0))
|
|
123
|
+
lcl_m = np.clip((t[int(origin)] - t_lcl) / 0.0098, 0, 4000) # dry adiabatic metres above the origin level
|
|
124
|
+
return cape.astype(np.float32), cin.astype(np.float32), lcl_m.astype(np.float32)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
__all__ = ["cape_cin", "dewpoint_k", "lcl", "mixing_ratio", "parcel_profile", "saturation_vapour_pressure",
|
|
128
|
+
"theta_e_bolton", "vapour_pressure_from_q"]
|