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.
Files changed (93) hide show
  1. pluvium/__init__.py +15 -0
  2. pluvium/advection.py +70 -0
  3. pluvium/atmos/__init__.py +33 -0
  4. pluvium/atmos/assemble.py +63 -0
  5. pluvium/atmos/cyclones.py +96 -0
  6. pluvium/atmos/diurnal.py +40 -0
  7. pluvium/atmos/moisture.py +66 -0
  8. pluvium/atmos/saharan.py +36 -0
  9. pluvium/atmos/sea_breeze.py +50 -0
  10. pluvium/atmos/thermo.py +128 -0
  11. pluvium/atmos/upslope.py +56 -0
  12. pluvium/atmos/waves.py +39 -0
  13. pluvium/bench.py +254 -0
  14. pluvium/benchmarks/__init__.py +3 -0
  15. pluvium/benchmarks/operational.py +879 -0
  16. pluvium/calibrate.py +147 -0
  17. pluvium/card.py +146 -0
  18. pluvium/chain.py +256 -0
  19. pluvium/cli.py +157 -0
  20. pluvium/convection.py +90 -0
  21. pluvium/dataset.py +424 -0
  22. pluvium/domain.py +510 -0
  23. pluvium/engine/__init__.py +28 -0
  24. pluvium/engine/experts.py +38 -0
  25. pluvium/engine/lead_film.py +53 -0
  26. pluvium/engine/mc_heads.py +31 -0
  27. pluvium/engine/refiner.py +71 -0
  28. pluvium/engine/temporal_mixer.py +34 -0
  29. pluvium/engine/terrain_patch.py +55 -0
  30. pluvium/features.py +304 -0
  31. pluvium/flood.py +180 -0
  32. pluvium/forecast.py +540 -0
  33. pluvium/guard/__init__.py +24 -0
  34. pluvium/guard/consistency.py +71 -0
  35. pluvium/guard/drift.py +58 -0
  36. pluvium/guard/fallback.py +67 -0
  37. pluvium/guard/freshness.py +47 -0
  38. pluvium/guard/inputs.py +112 -0
  39. pluvium/hydro/__init__.py +18 -0
  40. pluvium/hydro/antecedent.py +39 -0
  41. pluvium/hydro/hydrograph.py +56 -0
  42. pluvium/hydro/runoff.py +47 -0
  43. pluvium/insight/__init__.py +16 -0
  44. pluvium/insight/attribution.py +68 -0
  45. pluvium/insight/cases.py +42 -0
  46. pluvium/insight/errors.py +50 -0
  47. pluvium/losses.py +202 -0
  48. pluvium/model.py +591 -0
  49. pluvium/narrative.py +98 -0
  50. pluvium/online.py +242 -0
  51. pluvium/ops/__init__.py +14 -0
  52. pluvium/ops/api.py +93 -0
  53. pluvium/ops/loop.py +115 -0
  54. pluvium/physics.py +177 -0
  55. pluvium/prob/__init__.py +23 -0
  56. pluvium/prob/conformal.py +52 -0
  57. pluvium/prob/copula.py +74 -0
  58. pluvium/prob/diagnostics.py +57 -0
  59. pluvium/prob/extremes.py +91 -0
  60. pluvium/prob/scores.py +88 -0
  61. pluvium/products.py +192 -0
  62. pluvium/program.py +588 -0
  63. pluvium/render/__init__.py +16 -0
  64. pluvium/render/alerts.py +77 -0
  65. pluvium/render/bulletin.py +44 -0
  66. pluvium/render/maps.py +59 -0
  67. pluvium/schedule.py +67 -0
  68. pluvium/sources/__init__.py +3 -0
  69. pluvium/sources/baselines.py +216 -0
  70. pluvium/sources/cmorph.py +226 -0
  71. pluvium/sources/era5.py +215 -0
  72. pluvium/sources/glm.py +79 -0
  73. pluvium/sources/goes.py +157 -0
  74. pluvium/sources/metar.py +80 -0
  75. pluvium/sources/mrms.py +84 -0
  76. pluvium/sources/nwp.py +382 -0
  77. pluvium/sources/stations.py +204 -0
  78. pluvium/synthetic.py +206 -0
  79. pluvium/train.py +571 -0
  80. pluvium/training/__init__.py +20 -0
  81. pluvium/training/augment.py +53 -0
  82. pluvium/training/curriculum.py +58 -0
  83. pluvium/training/distill.py +38 -0
  84. pluvium/training/ema.py +50 -0
  85. pluvium/util.py +144 -0
  86. pluvium/verify.py +218 -0
  87. pluvium/weather.py +283 -0
  88. pluvium/zarrlite.py +166 -0
  89. pluvium-0.1.0.dist-info/METADATA +171 -0
  90. pluvium-0.1.0.dist-info/RECORD +93 -0
  91. pluvium-0.1.0.dist-info/WHEEL +4 -0
  92. pluvium-0.1.0.dist-info/entry_points.txt +2 -0
  93. 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"]
@@ -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"]
@@ -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"]
@@ -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"]