quantmet-tools 0.4.4__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.
- quantmet/__init__.py +10 -0
- quantmet/conservation.py +200 -0
- quantmet/constants.py +69 -0
- quantmet/dct_spectra.py +105 -0
- quantmet/degdays.py +386 -0
- quantmet/ensemble.py +120 -0
- quantmet/epflux.py +306 -0
- quantmet/gill.py +286 -0
- quantmet/helmholtz.py +161 -0
- quantmet/packets.py +51 -0
- quantmet/pv.py +143 -0
- quantmet/qbo.py +112 -0
- quantmet/stats.py +368 -0
- quantmet/tc.py +320 -0
- quantmet/tc_detect.c +116 -0
- quantmet/tc_detect.py +181 -0
- quantmet/tem.py +360 -0
- quantmet/thermo.py +860 -0
- quantmet/verify.py +425 -0
- quantmet/waf.py +598 -0
- quantmet/wk_filter.py +202 -0
- quantmet_tools-0.4.4.dist-info/METADATA +461 -0
- quantmet_tools-0.4.4.dist-info/RECORD +26 -0
- quantmet_tools-0.4.4.dist-info/WHEEL +5 -0
- quantmet_tools-0.4.4.dist-info/licenses/LICENSE +9 -0
- quantmet_tools-0.4.4.dist-info/top_level.txt +1 -0
quantmet/helmholtz.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Global Helmholtz decomposition by spherical-harmonic Poisson inversion.
|
|
2
|
+
|
|
3
|
+
Given u, v on the sphere, compute divergence and vorticity, invert the
|
|
4
|
+
Laplacian spectrally (chi_lm = -a^2 delta_lm / l(l+1); psi_lm likewise from
|
|
5
|
+
vorticity), and return velocity potential / streamfunction plus the
|
|
6
|
+
irrotational (divergent) and nondivergent wind components.
|
|
7
|
+
|
|
8
|
+
Implementation notes that matter in practice:
|
|
9
|
+
- Wind is sampled on a Gauss-Legendre (GLQ) grid before differentiating, so
|
|
10
|
+
there are no pole points and no 1/cos(phi) blowups in the divergence.
|
|
11
|
+
- The l=0 mode is excluded (undefined for the inverse Laplacian).
|
|
12
|
+
- cos(phi) is clipped when evaluating grad(chi) near the poles of the
|
|
13
|
+
output Driscoll-Healy grid.
|
|
14
|
+
- The input is wrapped in longitude (a column at lon + 360 and one at
|
|
15
|
+
lon - 360) and, if it lacks them, given pole rows before it is interpolated
|
|
16
|
+
to the GLQ grid, so any regular global grid works at any lmax. (Before
|
|
17
|
+
0.3.1 a 1.5 or 2.5 deg grid at lmax = 120 left the GLQ columns past its
|
|
18
|
+
last longitude NaN, and chi came out entirely NaN.)
|
|
19
|
+
- Longitude derivatives are periodic centred differences on every global
|
|
20
|
+
grid (GLQ and the extended DH2 output, whose 360 column repeats 0); before
|
|
21
|
+
0.3.1 np.gradient's one-sided differences were used at the seam columns.
|
|
22
|
+
|
|
23
|
+
Powers the Walker-circulation monitor at https://scorvec.com/circulation.html
|
|
24
|
+
(velocity potential of the 200 hPa divergent wind, and the zonal mass
|
|
25
|
+
streamfunction built from the divergent zonal wind), and supplies the
|
|
26
|
+
streamfunction for the wave-activity flux (quantmet.waf).
|
|
27
|
+
|
|
28
|
+
Accuracy: vorticity and divergence are second-order finite differences on
|
|
29
|
+
the GLQ grid, so the inversion error falls roughly as lmax^-1.7 (solid-body
|
|
30
|
+
rotation: 2.5 % at lmax = 31, 0.8 % at 63, 0.24 % at 127).
|
|
31
|
+
|
|
32
|
+
The output grid is pyshtools' extended Driscoll-Healy grid: it includes
|
|
33
|
+
both poles and repeats longitude 0 at 360.
|
|
34
|
+
|
|
35
|
+
Requires: pyshtools, xarray.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
import xarray as xr
|
|
42
|
+
import pyshtools as pysh
|
|
43
|
+
|
|
44
|
+
from .constants import A_EARTH
|
|
45
|
+
|
|
46
|
+
__all__ = ["velocity_potential", "streamfunction", "irrotational_wind", "nondivergent_wind"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _to_0360(da: xr.DataArray) -> xr.DataArray:
|
|
50
|
+
"""Longitudes mapped to [0, 360) and sorted (a repeated seam column dropped); latitudes ascending."""
|
|
51
|
+
lon = np.mod(da["longitude"].values.astype(float), 360.0)
|
|
52
|
+
da = da.assign_coords(longitude=lon).sortby("longitude")
|
|
53
|
+
keep = np.r_[True, np.diff(da["longitude"].values) > 1e-9]
|
|
54
|
+
return da.isel(longitude=np.flatnonzero(keep)).sortby("latitude")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pole_row(row: np.ndarray) -> np.ndarray:
|
|
58
|
+
"""A wind component at a pole: only zonal wavenumber 1 of the nearest row survives (a cross-polar flow seen in
|
|
59
|
+
local east/north components), so that is what the pole row gets."""
|
|
60
|
+
c = np.fft.rfft(row)
|
|
61
|
+
keep = np.zeros_like(c)
|
|
62
|
+
keep[1:2] = c[1:2]
|
|
63
|
+
return np.fft.irfft(keep, n=row.size)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _padded(da: xr.DataArray) -> xr.DataArray:
|
|
67
|
+
"""Wrap one column each side in longitude, and add pole rows if the grid stops short of the poles, so that
|
|
68
|
+
interpolation to any GLQ grid stays inside the data."""
|
|
69
|
+
lon = da["longitude"].values
|
|
70
|
+
da = xr.concat(
|
|
71
|
+
[
|
|
72
|
+
da.isel(longitude=[-1]).assign_coords(longitude=[lon[-1] - 360.0]),
|
|
73
|
+
da,
|
|
74
|
+
da.isel(longitude=[0]).assign_coords(longitude=[lon[0] + 360.0]),
|
|
75
|
+
],
|
|
76
|
+
dim="longitude",
|
|
77
|
+
)
|
|
78
|
+
da = da.transpose("latitude", "longitude")
|
|
79
|
+
lat = da["latitude"].values
|
|
80
|
+
rows, lats = [], []
|
|
81
|
+
if lat[0] > -90.0 + 1e-9:
|
|
82
|
+
rows.append(_pole_row(da.values[0])[None])
|
|
83
|
+
lats.append([-90.0])
|
|
84
|
+
rows.append(da.values)
|
|
85
|
+
lats.append(lat)
|
|
86
|
+
if lat[-1] < 90.0 - 1e-9:
|
|
87
|
+
rows.append(_pole_row(da.values[-1])[None])
|
|
88
|
+
lats.append([90.0])
|
|
89
|
+
return xr.DataArray(
|
|
90
|
+
np.concatenate(rows, 0),
|
|
91
|
+
dims=("latitude", "longitude"),
|
|
92
|
+
coords={"latitude": np.concatenate(lats), "longitude": da["longitude"].values},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _ddlon(f: np.ndarray, lon_deg) -> np.ndarray:
|
|
97
|
+
"""d f / d lambda (per radian) along the last axis: periodic centred differences on a global uniform grid (with
|
|
98
|
+
or without a repeated 360 column), np.gradient on a regional one."""
|
|
99
|
+
lon = np.asarray(lon_deg, float)
|
|
100
|
+
d = np.diff(lon)
|
|
101
|
+
dl = d.mean()
|
|
102
|
+
if np.allclose(d, dl, rtol=1e-6, atol=1e-9):
|
|
103
|
+
dlr = np.deg2rad(dl)
|
|
104
|
+
span = lon[-1] - lon[0]
|
|
105
|
+
if np.isclose(span, 360.0, atol=1e-6): # repeated seam column (DH2 extended grid)
|
|
106
|
+
core = f[..., :-1]
|
|
107
|
+
g = (np.roll(core, -1, axis=-1) - np.roll(core, 1, axis=-1)) / (2 * dlr)
|
|
108
|
+
return np.concatenate([g, g[..., :1]], axis=-1)
|
|
109
|
+
if np.isclose(span + dl, 360.0, atol=1e-6): # full circle, no repeat (GLQ)
|
|
110
|
+
return (np.roll(f, -1, axis=-1) - np.roll(f, 1, axis=-1)) / (2 * dlr)
|
|
111
|
+
return np.gradient(f, np.deg2rad(lon), axis=-1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _laplace_invert(field: np.ndarray, lmax: int) -> pysh.SHGrid:
|
|
115
|
+
clm = pysh.SHGrid.from_array(field, grid="GLQ").expand()
|
|
116
|
+
l = np.arange(clm.lmax + 1, dtype=float)
|
|
117
|
+
fac = np.zeros_like(l)
|
|
118
|
+
fac[1:] = -(A_EARTH**2) / (l[1:] * (l[1:] + 1))
|
|
119
|
+
out = clm.copy()
|
|
120
|
+
out.coeffs *= fac[None, :, None]
|
|
121
|
+
return out.expand(grid="DH2")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _grid_uv(u2d: xr.DataArray, v2d: xr.DataArray, lmax: int):
|
|
125
|
+
u2d, v2d = _padded(_to_0360(u2d)), _padded(_to_0360(v2d))
|
|
126
|
+
glat, glon = pysh.expand.GLQGridCoord(lmax)
|
|
127
|
+
ug = u2d.interp(latitude=glat, longitude=glon).values
|
|
128
|
+
vg = v2d.interp(latitude=glat, longitude=glon).values
|
|
129
|
+
return ug, vg, np.deg2rad(glat), np.deg2rad(glon)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def velocity_potential(u2d: xr.DataArray, v2d: xr.DataArray, lmax: int = 120):
|
|
133
|
+
"""chi (m^2/s) with its output-grid lats/lons: inverts del^2 chi = div."""
|
|
134
|
+
ug, vg, latr, lonr = _grid_uv(u2d, v2d, lmax)
|
|
135
|
+
cosp = np.cos(latr)[:, None]
|
|
136
|
+
div = (_ddlon(ug, np.rad2deg(lonr)) + np.gradient(vg * cosp, latr, axis=0)) / (A_EARTH * cosp)
|
|
137
|
+
g = _laplace_invert(div, lmax)
|
|
138
|
+
return g.data, np.array(g.lats()), np.array(g.lons())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def streamfunction(u2d: xr.DataArray, v2d: xr.DataArray, lmax: int = 120):
|
|
142
|
+
"""psi (m^2/s): inverts del^2 psi = zeta (relative vorticity)."""
|
|
143
|
+
ug, vg, latr, lonr = _grid_uv(u2d, v2d, lmax)
|
|
144
|
+
cosp = np.cos(latr)[:, None]
|
|
145
|
+
zeta = (_ddlon(vg, np.rad2deg(lonr)) - np.gradient(ug * cosp, latr, axis=0)) / (A_EARTH * cosp)
|
|
146
|
+
g = _laplace_invert(zeta, lmax)
|
|
147
|
+
return g.data, np.array(g.lats()), np.array(g.lons())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def irrotational_wind(chi: np.ndarray, dlat: np.ndarray, dlon: np.ndarray):
|
|
151
|
+
"""(u_chi, v_chi) = grad(chi) on the chi grid (periodic in longitude on a global grid)."""
|
|
152
|
+
latr = np.deg2rad(dlat)
|
|
153
|
+
cosp = np.clip(np.cos(latr), 1e-3, None)[:, None]
|
|
154
|
+
return (_ddlon(chi, dlon) / (A_EARTH * cosp), np.gradient(chi, latr, axis=0) / A_EARTH)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def nondivergent_wind(psi: np.ndarray, dlat: np.ndarray, dlon: np.ndarray):
|
|
158
|
+
"""(u_psi, v_psi) = k x grad(psi) on the psi grid (periodic in longitude on a global grid)."""
|
|
159
|
+
latr = np.deg2rad(dlat)
|
|
160
|
+
cosp = np.clip(np.cos(latr), 1e-3, None)[:, None]
|
|
161
|
+
return (-np.gradient(psi, latr, axis=0) / A_EARTH, _ddlon(psi, dlon) / (A_EARTH * cosp))
|
quantmet/packets.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Rossby wave-packet envelope (Zimin, Szunyogh, Patil, Hunt & Ott 2003, MWR 131).
|
|
2
|
+
|
|
3
|
+
The envelope of the meridional wind along each latitude circle: keep zonal wavenumbers kmin..kmax, build the analytic
|
|
4
|
+
signal (positive wavenumbers doubled, negative zeroed, so the real part is the band-passed field) and take its modulus.
|
|
5
|
+
A train of troughs and ridges then reads as ONE positive hump instead of alternating signs, so a longitude-time
|
|
6
|
+
(Hovmoller) plot of the envelope shows a wave packet as a single bright streak whose slope is the group velocity.
|
|
7
|
+
|
|
8
|
+
Implementation notes that matter in practice:
|
|
9
|
+
- The band matters: kmin ~4 removes the planetary stationary waves (whose envelope is just "everywhere"), kmax ~15
|
|
10
|
+
removes grid-scale noise. Zimin et al. use 4-17 for 300 hPa v.
|
|
11
|
+
- The transform is along longitude only, latitude by latitude; average the ENVELOPE over a latitude band (not v),
|
|
12
|
+
since v changes sign across a packet's meridional structure.
|
|
13
|
+
- For the analytic signal to be meaningful the band must be well inside (0, n/2); the function checks it.
|
|
14
|
+
|
|
15
|
+
Powers the wave-packet panel of the tropical-cyclone / jet-stream card at https://scorvec.com/circulation.html.
|
|
16
|
+
|
|
17
|
+
Requires: numpy only.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
__all__ = ["packet_envelope", "band_mean"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def packet_envelope(v: np.ndarray, kmin: int = 4, kmax: int = 15) -> np.ndarray:
|
|
28
|
+
"""Envelope (same units as v) of v(..., lon) for zonal wavenumbers kmin..kmax; the LAST axis is longitude and must
|
|
29
|
+
span the full circle with uniform spacing. NaNs are treated as zero."""
|
|
30
|
+
v = np.nan_to_num(np.asarray(v, float))
|
|
31
|
+
n = v.shape[-1]
|
|
32
|
+
if not (1 <= kmin <= kmax < n // 2):
|
|
33
|
+
raise ValueError(f"need 1 <= kmin <= kmax < nlon/2 (got {kmin}, {kmax}, nlon {n})")
|
|
34
|
+
F = np.fft.fft(v, axis=-1)
|
|
35
|
+
Z = np.zeros_like(F)
|
|
36
|
+
Z[..., kmin : kmax + 1] = 2.0 * F[..., kmin : kmax + 1]
|
|
37
|
+
return np.abs(np.fft.ifft(Z, axis=-1))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def band_mean(field: np.ndarray, lat: np.ndarray, lat_min: float, lat_max: float, lat_axis: int = -2) -> np.ndarray:
|
|
41
|
+
"""cos(lat)-weighted mean of field over lat_min..lat_max along ``lat_axis`` (removed from the output)."""
|
|
42
|
+
field = np.asarray(field, float)
|
|
43
|
+
lat = np.asarray(lat, float)
|
|
44
|
+
m = (lat >= lat_min) & (lat <= lat_max)
|
|
45
|
+
if not m.any():
|
|
46
|
+
raise ValueError("no latitudes in the band")
|
|
47
|
+
w = np.cos(np.deg2rad(lat[m]))
|
|
48
|
+
sub = np.compress(m, field, axis=lat_axis)
|
|
49
|
+
shape = [1] * sub.ndim
|
|
50
|
+
shape[lat_axis] = -1
|
|
51
|
+
return (sub * w.reshape(shape)).sum(axis=lat_axis) / w.sum()
|
quantmet/pv.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Ertel potential vorticity on pressure levels, the dynamic tropopause, and isentropic PV.
|
|
2
|
+
|
|
3
|
+
PV = -g [ (zeta + f) d(theta)/dp - (dv/dp)(d(theta)/dx) + (du/dp)(d(theta)/dy) ] (PVU = 1e-6 K m^2 kg^-1 s^-1)
|
|
4
|
+
|
|
5
|
+
computed on the pressure grid from T, u, v snapshots (lev, lat, lon) on a regular, global-in-longitude
|
|
6
|
+
latitude-longitude grid. The dynamic tropopause (DT) is the 2-PVU surface; theta, p and the wind on it are found by
|
|
7
|
+
searching UPWARD from the lowest level and interpolating linearly in PV between the bracketing levels (p in ln p).
|
|
8
|
+
Isentropic PV interpolates linearly in theta.
|
|
9
|
+
|
|
10
|
+
Implementation notes that matter in practice:
|
|
11
|
+
- Pressure derivatives use np.gradient on the ACTUAL pressure levels (second order on a non-uniform grid);
|
|
12
|
+
horizontal derivatives are centred, periodic in longitude.
|
|
13
|
+
- PV does not survive ensemble averaging: a mean of fifty tropopause folds is a smear, so compute it per member
|
|
14
|
+
(or on one member) and never on the ensemble-mean fields.
|
|
15
|
+
- A light 3-point smoothing in each horizontal direction (``smooth=True``, the default) tames the grid-scale noise of
|
|
16
|
+
zeta on a 0.25 deg grid; turn it off for coarse grids or analysis work.
|
|
17
|
+
- Where a column never reaches the threshold (the deep tropics, when the data stop at 100 hPa) the tropopause is at or
|
|
18
|
+
above the top level. ``dynamic_tropopause`` then returns the TOP-level values (a lower bound on theta, flagged in
|
|
19
|
+
``never``) instead of a hole -- set ``fill_top=False`` to get NaN there.
|
|
20
|
+
- The upward search takes the FIRST crossing, so a stratospheric intrusion folded under tropospheric air (a
|
|
21
|
+
tropopause fold) is found at its lower edge; that is the conventional DT choice.
|
|
22
|
+
|
|
23
|
+
Powers the dynamic-tropopause loops (theta on 2 PVU, PV on 330/350 K) at https://scorvec.com/circulation.html.
|
|
24
|
+
|
|
25
|
+
References: Hoskins, McIntyre & Robertson (1985, QJRMS); Morgan & Nielsen-Gammon (1998, MWR) on DT maps.
|
|
26
|
+
|
|
27
|
+
Requires: numpy only.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from .constants import A_EARTH, KAPPA, OMEGA, P0
|
|
35
|
+
from .constants import G as G0
|
|
36
|
+
|
|
37
|
+
__all__ = ["potential_temperature", "ertel_pv", "dynamic_tropopause", "on_isentrope"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def potential_temperature(t: np.ndarray, p_pa: np.ndarray) -> np.ndarray:
|
|
41
|
+
"""theta (K) from T (K) on levels p_pa (Pa) along axis 0."""
|
|
42
|
+
p = np.asarray(p_pa, float).reshape((-1,) + (1,) * (np.ndim(t) - 1))
|
|
43
|
+
return np.asarray(t, float) * (P0 / p) ** KAPPA
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ertel_pv(
|
|
47
|
+
t: np.ndarray, u: np.ndarray, v: np.ndarray, p_pa: np.ndarray, lat: np.ndarray, lon: np.ndarray, smooth: bool = True
|
|
48
|
+
):
|
|
49
|
+
"""Ertel PV (PVU) and theta (K) on the pressure grid for one time.
|
|
50
|
+
|
|
51
|
+
t, u, v : (lev, lat, lon); p_pa : (lev,) in Pa (any order, monotonic); lat, lon in degrees, regular, lon global
|
|
52
|
+
(periodic). Returns (pv, theta), both (lev, lat, lon). Southern-hemisphere PV is negative (f < 0)."""
|
|
53
|
+
t = np.asarray(t, float)
|
|
54
|
+
u = np.asarray(u, float)
|
|
55
|
+
v = np.asarray(v, float)
|
|
56
|
+
p_pa = np.asarray(p_pa, float)
|
|
57
|
+
th = potential_temperature(t, p_pa)
|
|
58
|
+
phi = np.deg2rad(np.asarray(lat, float))
|
|
59
|
+
lam = np.deg2rad(np.asarray(lon, float))
|
|
60
|
+
cosphi = np.clip(np.cos(phi), 1e-6, None)[None, :, None]
|
|
61
|
+
dlam = lam[1] - lam[0]
|
|
62
|
+
|
|
63
|
+
def ddx(a):
|
|
64
|
+
return (np.roll(a, -1, -1) - np.roll(a, 1, -1)) / (2 * dlam) / (A_EARTH * cosphi)
|
|
65
|
+
|
|
66
|
+
def ddy(a):
|
|
67
|
+
return np.gradient(a, phi, axis=1) / A_EARTH
|
|
68
|
+
|
|
69
|
+
zeta = ddx(v) - ddy(u * cosphi) / cosphi
|
|
70
|
+
f = (2 * OMEGA * np.sin(phi))[None, :, None]
|
|
71
|
+
th_p = np.gradient(th, p_pa, axis=0)
|
|
72
|
+
u_p = np.gradient(u, p_pa, axis=0)
|
|
73
|
+
v_p = np.gradient(v, p_pa, axis=0)
|
|
74
|
+
pv = -G0 * ((zeta + f) * th_p - v_p * ddx(th) + u_p * ddy(th)) * 1e6
|
|
75
|
+
if smooth:
|
|
76
|
+
pv = (pv + np.roll(pv, 1, -1) + np.roll(pv, -1, -1)) / 3.0
|
|
77
|
+
pv[:, 1:-1] = (pv[:, :-2] + pv[:, 1:-1] + pv[:, 2:]) / 3.0
|
|
78
|
+
return pv, th
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _bottom_up(p_pa):
|
|
82
|
+
"""Index order putting the highest pressure (lowest level) first."""
|
|
83
|
+
return np.argsort(-np.asarray(p_pa, float))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def dynamic_tropopause(
|
|
87
|
+
pv: np.ndarray,
|
|
88
|
+
theta: np.ndarray,
|
|
89
|
+
u: np.ndarray,
|
|
90
|
+
v: np.ndarray,
|
|
91
|
+
p_pa: np.ndarray,
|
|
92
|
+
thr: float = 2.0,
|
|
93
|
+
fill_top: bool = True,
|
|
94
|
+
) -> dict:
|
|
95
|
+
"""theta, p (hPa), u, v on the ``thr``-PVU surface, searching upward from the lowest level.
|
|
96
|
+
|
|
97
|
+
Use |PV| (``np.abs(pv)``) for a both-hemisphere search. Returns a dict with 'theta', 'p', 'u', 'v' (lat, lon) and
|
|
98
|
+
'never' (bool: the column never reached thr; values there are the top level's if fill_top, else NaN)."""
|
|
99
|
+
o = _bottom_up(p_pa)
|
|
100
|
+
pv, th, u, v = (np.asarray(x, float)[o] for x in (pv, theta, u, v))
|
|
101
|
+
p = np.asarray(p_pa, float)[o]
|
|
102
|
+
above = pv >= thr
|
|
103
|
+
nl = pv.shape[0]
|
|
104
|
+
idx = np.argmax(above, axis=0)
|
|
105
|
+
never = ~above.any(axis=0)
|
|
106
|
+
k1 = np.clip(idx, 1, nl - 1)
|
|
107
|
+
k0 = k1 - 1
|
|
108
|
+
jj, ii = np.meshgrid(np.arange(pv.shape[1]), np.arange(pv.shape[2]), indexing="ij")
|
|
109
|
+
pv0, pv1 = pv[k0, jj, ii], pv[k1, jj, ii]
|
|
110
|
+
w = np.clip((thr - pv0) / np.where(np.abs(pv1 - pv0) < 1e-6, 1e-6, pv1 - pv0), 0, 1)
|
|
111
|
+
w = np.where(idx == 0, 0.0, w) # already above thr at the lowest level: that level itself
|
|
112
|
+
k0 = np.where(idx == 0, 0, k0)
|
|
113
|
+
k1 = np.where(idx == 0, 0, k1)
|
|
114
|
+
|
|
115
|
+
def interp(a):
|
|
116
|
+
return a[k0, jj, ii] * (1 - w) + a[k1, jj, ii] * w
|
|
117
|
+
|
|
118
|
+
lnp = np.log(p)[:, None, None] * np.ones_like(pv)
|
|
119
|
+
out = {"theta": interp(th), "p": np.exp(interp(lnp)) / 100.0, "u": interp(u), "v": interp(v)}
|
|
120
|
+
top = {"theta": th[-1], "p": np.full(th.shape[1:], p[-1] / 100.0), "u": u[-1], "v": v[-1]}
|
|
121
|
+
for k in out:
|
|
122
|
+
out[k] = np.where(never, top[k] if fill_top else np.nan, out[k])
|
|
123
|
+
out["never"] = never
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def on_isentrope(pv: np.ndarray, theta: np.ndarray, u: np.ndarray, v: np.ndarray, theta0: float) -> dict:
|
|
128
|
+
"""PV and wind interpolated linearly in theta onto the theta0 isentrope; NaN where theta0 is outside the column.
|
|
129
|
+
Assumes theta increases monotonically with height (statically stable); levels in any order."""
|
|
130
|
+
th = np.asarray(theta, float)
|
|
131
|
+
o = np.argsort(th.mean(axis=(1, 2))) # theta ascending = upward
|
|
132
|
+
pv, th, u, v = (np.asarray(x, float)[o] for x in (pv, th, u, v))
|
|
133
|
+
nl = pv.shape[0]
|
|
134
|
+
idx = np.clip(np.sum(th <= theta0, axis=0) - 1, 0, nl - 2)
|
|
135
|
+
jj, ii = np.meshgrid(np.arange(pv.shape[1]), np.arange(pv.shape[2]), indexing="ij")
|
|
136
|
+
t0, t1 = th[idx, jj, ii], th[idx + 1, jj, ii]
|
|
137
|
+
w = np.clip((theta0 - t0) / np.where(np.abs(t1 - t0) < 1e-3, 1e-3, t1 - t0), 0, 1)
|
|
138
|
+
valid = (th.min(axis=0) <= theta0) & (th.max(axis=0) >= theta0)
|
|
139
|
+
|
|
140
|
+
def interp(a):
|
|
141
|
+
return np.where(valid, a[idx, jj, ii] * (1 - w) + a[idx + 1, jj, ii] * w, np.nan)
|
|
142
|
+
|
|
143
|
+
return {"pv": interp(pv), "u": interp(u), "v": interp(v)}
|
quantmet/qbo.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The QBO zero-wind line: where the zonal-mean zonal wind turns easterly on
|
|
2
|
+
the equatorward side of the winter westerlies.
|
|
3
|
+
|
|
4
|
+
Holton and Tan (1980): planetary waves propagating up and equatorward from
|
|
5
|
+
the winter midlatitudes meet a critical line where [u] = 0. With easterly
|
|
6
|
+
QBO winds in the lower stratosphere that line sits in the winter
|
|
7
|
+
subtropics, the extratropical waveguide narrows and more wave activity is
|
|
8
|
+
steered toward the polar vortex. zero_line tracks WHERE the line is, one
|
|
9
|
+
latitude per profile, so it can be followed day by day, level by level and
|
|
10
|
+
member by member.
|
|
11
|
+
|
|
12
|
+
The rule (defaults are the site's, chosen on robustness before looking at
|
|
13
|
+
any vortex relation):
|
|
14
|
+
1. Smooth [u] in latitude with a Gaussian of `sigma` degrees.
|
|
15
|
+
2. Start at the strongest westerlies between start = (25, 45) degrees in
|
|
16
|
+
the hemisphere asked for. If even that maximum is <= 0 there are no
|
|
17
|
+
winter westerlies to bound: undefined, flag 1 (most of May-September in
|
|
18
|
+
the NH).
|
|
19
|
+
3. Scan toward and across the equator. The index is the first zero
|
|
20
|
+
crossing (linear interpolation) whose easterly region beyond it reaches
|
|
21
|
+
at least `umin` (1 m/s).
|
|
22
|
+
4. No qualifying crossing before `bound` degrees in the other hemisphere:
|
|
23
|
+
censored at -bound, flag 2.
|
|
24
|
+
|
|
25
|
+
Numerical notes that matter in practice:
|
|
26
|
+
- THE DIP RULE. A zero crossing is only a critical line if the easterlies
|
|
27
|
+
beyond it are real. Shallow dips (min |u| < umin, inside analysis noise)
|
|
28
|
+
are stepped over and the scan continues past them: without that, a
|
|
29
|
+
-0.3 m/s wobble at 18N in a westerly-QBO autumn reads as a critical line
|
|
30
|
+
and the index jumps 20 degrees for a day. The smoothing alone does not fix
|
|
31
|
+
it (a dip wider than sigma survives), and raising sigma blurs the real
|
|
32
|
+
line; the amplitude test is what separates them.
|
|
33
|
+
- CENSORING IS INFORMATION. With westerly QBO winds at a level there may be
|
|
34
|
+
no easterlies anywhere within reach; that is flag 2 at -bound, not a
|
|
35
|
+
missing value, and statistics must treat it as a censored observation.
|
|
36
|
+
- SOUTHERN HEMISPHERE by mirroring the grid: the SH index is the NH rule on
|
|
37
|
+
-lat, returned negative (positive = north throughout).
|
|
38
|
+
|
|
39
|
+
Powers the QBO zero-wind-line tracker on
|
|
40
|
+
https://scorvec.com/stratosphere.html (MERRA-2 reference, GEOS FP analyses
|
|
41
|
+
and per-member AIFS-ENS / IFS-ENS forecasts).
|
|
42
|
+
|
|
43
|
+
Requires: numpy, scipy (Gaussian smoothing).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
|
|
50
|
+
SIGMA, UMIN, START, BOUND = 2.0, 1.0, (25.0, 45.0), 45.0
|
|
51
|
+
|
|
52
|
+
__all__ = ["zero_line", "SIGMA", "UMIN", "START", "BOUND"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def zero_line(u, lat, hemi: str = "nh", sigma: float = SIGMA, umin: float = UMIN, start=START, bound: float = BOUND):
|
|
56
|
+
"""Zero-wind-line latitude of [u] profiles.
|
|
57
|
+
|
|
58
|
+
u : (..., lat) zonal-mean zonal wind, m/s; lat in any order, uniformly
|
|
59
|
+
spaced (degrees).
|
|
60
|
+
hemi : "nh" or "sh", the winter hemisphere whose westerlies bound it.
|
|
61
|
+
Returns (index, flag), both with u's leading shape: index in degrees
|
|
62
|
+
(positive north; NaN where flag == 1, -bound / +bound where censored),
|
|
63
|
+
flag 0 = found, 1 = undefined (no westerlies to bound), 2 = censored."""
|
|
64
|
+
from scipy.ndimage import gaussian_filter1d
|
|
65
|
+
|
|
66
|
+
if hemi not in ("nh", "sh"):
|
|
67
|
+
raise ValueError("hemi must be 'nh' or 'sh'")
|
|
68
|
+
lat = np.asarray(lat, float)
|
|
69
|
+
u = np.asarray(u, float)
|
|
70
|
+
o = np.argsort(lat)
|
|
71
|
+
lat, u = lat[o], u[..., o]
|
|
72
|
+
if hemi == "sh":
|
|
73
|
+
lat, u = -lat[::-1], u[..., ::-1]
|
|
74
|
+
if sigma > 0:
|
|
75
|
+
u = gaussian_filter1d(u, sigma / abs(lat[1] - lat[0]), axis=-1, mode="nearest")
|
|
76
|
+
shp = u.shape[:-1]
|
|
77
|
+
U = u.reshape(-1, u.shape[-1])
|
|
78
|
+
keep = lat >= -bound
|
|
79
|
+
la, U = lat[keep], U[:, keep]
|
|
80
|
+
st = np.flatnonzero((la >= start[0]) & (la <= start[1]))
|
|
81
|
+
if st.size == 0:
|
|
82
|
+
raise ValueError("no grid latitude inside `start`")
|
|
83
|
+
z = np.full(U.shape[0], np.nan)
|
|
84
|
+
fl = np.zeros(U.shape[0], int)
|
|
85
|
+
for k in range(U.shape[0]):
|
|
86
|
+
row = U[k]
|
|
87
|
+
if not np.isfinite(row).all():
|
|
88
|
+
fl[k] = 1
|
|
89
|
+
continue
|
|
90
|
+
j = st[np.argmax(row[st])]
|
|
91
|
+
if row[j] <= 0:
|
|
92
|
+
fl[k] = 1
|
|
93
|
+
continue
|
|
94
|
+
found = False
|
|
95
|
+
while j > 0:
|
|
96
|
+
if row[j - 1] <= 0 < row[j]:
|
|
97
|
+
jj = j - 1
|
|
98
|
+
while jj >= 0 and row[jj] <= 0:
|
|
99
|
+
jj -= 1
|
|
100
|
+
if row[jj + 1 : j].min() <= -umin: # deep enough: a critical line
|
|
101
|
+
z[k] = la[j - 1] + (la[j] - la[j - 1]) * (0 - row[j - 1]) / (row[j] - row[j - 1])
|
|
102
|
+
found = True
|
|
103
|
+
break
|
|
104
|
+
if jj < 0:
|
|
105
|
+
break
|
|
106
|
+
j = jj + 1 # shallow dip: step over it
|
|
107
|
+
continue
|
|
108
|
+
j -= 1
|
|
109
|
+
if not found:
|
|
110
|
+
z[k], fl[k] = -bound, 2
|
|
111
|
+
z = z.reshape(shp)
|
|
112
|
+
return (-z if hemi == "sh" else z), fl.reshape(shp)
|