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