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/epflux.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Quasi-geostrophic Eliassen-Palm flux and its divergence on pressure levels.
|
|
2
|
+
|
|
3
|
+
Given u, v, T snapshots on (plev, lat, lon), compute the QG E-P flux of a
|
|
4
|
+
chosen planetary-wavenumber band and the flux divergence expressed as a zonal
|
|
5
|
+
force per unit mass (m/s/day) -- the wave driving of the zonal-mean flow:
|
|
6
|
+
|
|
7
|
+
F_phi = -a cos(phi) [u'v']
|
|
8
|
+
F_p = a cos(phi) f [v'theta'] / (d theta_bar / dp)
|
|
9
|
+
force = (div F) / (a cos(phi))
|
|
10
|
+
|
|
11
|
+
with primes the deviation from the zonal mean band-passed to zonal
|
|
12
|
+
wavenumbers 1..kmax and [.] the zonal mean (Edmon, Hoskins & McIntyre 1980).
|
|
13
|
+
|
|
14
|
+
Implementation notes that matter in practice:
|
|
15
|
+
- The static stability d(theta_bar)/dp is taken from a PROFILE, never
|
|
16
|
+
pointwise: by default (theta_p="zonal", since v0.3) the zonal-mean theta
|
|
17
|
+
[theta](phi, p); theta_p="global" uses the area-weighted global-mean
|
|
18
|
+
profile, a function of pressure only (the v0.1 choice, which carries
|
|
19
|
+
tropospheric stability above the polar tropopause and is 3-4x too small
|
|
20
|
+
there). A local d(theta)/dp crosses zero in the troposphere and blows the
|
|
21
|
+
heat-flux term up by many orders of magnitude. Either profile is floored at
|
|
22
|
+
-5e-5 K/Pa.
|
|
23
|
+
- cos(phi) is floored (85 deg) in the divergence *before* any smoothing.
|
|
24
|
+
Smoothing first lets the polar 1/cos(phi) blow-up bleed equatorward and
|
|
25
|
+
bury the real signal; the polar rows are masked in the returned force.
|
|
26
|
+
- Ensembles: the flux is quadratic in the eddies, so ensemble products must
|
|
27
|
+
average PER-MEMBER fluxes (`ensemble_ep_flux`), never take the flux of the
|
|
28
|
+
ensemble-mean fields -- ensemble averaging damps the eddies with lead time
|
|
29
|
+
and the flux fades with them.
|
|
30
|
+
|
|
31
|
+
- Axes: every function here reads longitude as the LAST axis and latitude as
|
|
32
|
+
the last axis of zonal means, with pressure just before latitude, whatever
|
|
33
|
+
leads them (a member axis, a lead axis): ep_flux takes (..., plev, lat, lon)
|
|
34
|
+
and returns (..., plev, lat) (since 0.3.1; 0.3.0 raised on a leading axis),
|
|
35
|
+
psfc (..., lat, lon); charney_drazin_* take (..., plev, lat). A band-pass or
|
|
36
|
+
smoothing along a fixed positional axis silently filters latitude -- or
|
|
37
|
+
members -- once a leading axis is added: a "k = 1-3" filter applied that
|
|
38
|
+
way returns meridional modes 1-3 of the whole field, not zonal wavenumbers
|
|
39
|
+
1-3. tests/test_epflux.py pins it. A leading member axis in ep_flux
|
|
40
|
+
gives the flux of EACH member; average them (or use ensemble_ep_flux) for
|
|
41
|
+
the ensemble flux.
|
|
42
|
+
|
|
43
|
+
charney_drazin_excess adds the vertical-propagation window for stationary
|
|
44
|
+
planetary waves (Charney & Drazin 1961): u - U_c for zonal wavenumber k, with
|
|
45
|
+
0 < u < U_c the corridor in which wave k can propagate upward.
|
|
46
|
+
|
|
47
|
+
Powers the live E-P flux & wave-driving forecast loop on
|
|
48
|
+
https://scorvec.com/stratosphere.html (AIFS-ENS and IFS-ENS, day 0-15; also
|
|
49
|
+
shown on https://scorvec.com/circulation.html).
|
|
50
|
+
|
|
51
|
+
Requires: numpy (flux); scipy for smooth_deg and for the smoothing in charney_drazin_excess.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
from dataclasses import dataclass
|
|
57
|
+
|
|
58
|
+
import numpy as np
|
|
59
|
+
|
|
60
|
+
from .constants import A_EARTH, G, KAPPA, OMEGA
|
|
61
|
+
|
|
62
|
+
P_REF = 1000.0 # hPa, for potential temperature
|
|
63
|
+
H_SCALE = 7000.0 # m, log-pressure scale height
|
|
64
|
+
__all__ = ["EPFlux", "zonal_bandpass", "ep_flux", "ensemble_ep_flux", "charney_drazin_uc", "charney_drazin_excess"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class EPFlux:
|
|
69
|
+
"""E-P flux and derived wave driving on the (plev, lat) plane.
|
|
70
|
+
|
|
71
|
+
fphi : meridional component, -a cos(phi) [u'v'] (m^3 s^-2)
|
|
72
|
+
fp : vertical (pressure) component, a cos f [v'th']/th_p (m^3 s^-2 Pa/m)
|
|
73
|
+
force : (div F)/(a cos(phi)) as m/s per DAY; polar rows (|lat| > 82) NaN
|
|
74
|
+
ubar : zonal-mean zonal wind (m/s)
|
|
75
|
+
lat : degrees; plev: hPa (as passed in)
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
fphi: np.ndarray
|
|
79
|
+
fp: np.ndarray
|
|
80
|
+
force: np.ndarray
|
|
81
|
+
ubar: np.ndarray
|
|
82
|
+
lat: np.ndarray
|
|
83
|
+
plev: np.ndarray
|
|
84
|
+
below: np.ndarray | None = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def zonal_bandpass(f: np.ndarray, kmax: int = 3, kmin: int = 1) -> np.ndarray:
|
|
88
|
+
"""Keep zonal wavenumbers kmin..kmax of f(..., lon) (last axis), zero the
|
|
89
|
+
mean and everything outside the band. Exactly real on output."""
|
|
90
|
+
F = np.fft.rfft(f, axis=-1)
|
|
91
|
+
F[..., :kmin] = 0.0
|
|
92
|
+
F[..., kmax + 1 :] = 0.0
|
|
93
|
+
return np.fft.irfft(F, n=f.shape[-1], axis=-1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def below_ground_share(psfc_pa: np.ndarray, plev: np.ndarray) -> np.ndarray:
|
|
97
|
+
"""(..., plev, lat) share of longitudes where the level lies below the surface (p_level > p_s); psfc_pa
|
|
98
|
+
(..., lat, lon) in Pa, plev in hPa."""
|
|
99
|
+
p = np.asarray(plev, float)[:, None, None] * 100.0
|
|
100
|
+
return (p > np.asarray(psfc_pa, float)[..., None, :, :]).mean(axis=-1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _nan_smooth_lat(f, sigma_pts):
|
|
104
|
+
"""Gaussian along latitude (the last axis) ignoring NaN (normalised convolution); NaN stays NaN."""
|
|
105
|
+
from scipy.ndimage import gaussian_filter1d
|
|
106
|
+
|
|
107
|
+
ok = np.isfinite(f)
|
|
108
|
+
num = gaussian_filter1d(np.where(ok, f, 0.0), sigma_pts, axis=-1, mode="nearest")
|
|
109
|
+
den = gaussian_filter1d(ok.astype(float), sigma_pts, axis=-1, mode="nearest")
|
|
110
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
111
|
+
out = num / den
|
|
112
|
+
out[~ok] = np.nan
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _quadratics(u, v, t, plev, kmax):
|
|
117
|
+
"""Per-snapshot zonal-mean quadratics: [u'v'], [v'th'], theta_bar, ubar."""
|
|
118
|
+
fac = (P_REF / np.asarray(plev, float))[:, None, None] ** KAPPA
|
|
119
|
+
th = t * fac
|
|
120
|
+
up = zonal_bandpass(u, kmax)
|
|
121
|
+
vp = zonal_bandpass(v, kmax)
|
|
122
|
+
thp = zonal_bandpass(th, kmax)
|
|
123
|
+
return ((up * vp).mean(axis=-1), (vp * thp).mean(axis=-1), th.mean(axis=-1), u.mean(axis=-1))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _assemble(
|
|
127
|
+
uv, vth, thbar, ubar, lat, plev, polar_mask_deg, theta_p="zonal", below=None, bg_frac=0.10, smooth_deg=0.0
|
|
128
|
+
):
|
|
129
|
+
lat = np.asarray(lat, float)
|
|
130
|
+
plev = np.asarray(plev, float)
|
|
131
|
+
if theta_p not in ("zonal", "global"):
|
|
132
|
+
raise ValueError("theta_p must be 'zonal' or 'global'")
|
|
133
|
+
latr = np.deg2rad(lat)
|
|
134
|
+
cosp = np.cos(latr) # arrays are (..., plev, lat): lat last, plev before it
|
|
135
|
+
f_cor = 2 * OMEGA * np.sin(latr)
|
|
136
|
+
p = plev * 100.0
|
|
137
|
+
lnp = np.log(p)
|
|
138
|
+
pc = p[:, None]
|
|
139
|
+
|
|
140
|
+
w = np.cos(latr)
|
|
141
|
+
if theta_p == "zonal":
|
|
142
|
+
dthdp = np.gradient(thbar, lnp, axis=-2) / pc
|
|
143
|
+
else:
|
|
144
|
+
th_prof = (thbar * w).sum(axis=-1) / w.sum()
|
|
145
|
+
dthdp = (np.gradient(th_prof, lnp, axis=-1) / p)[..., None]
|
|
146
|
+
dthdp = np.where(dthdp > -5e-5, -5e-5, dthdp)
|
|
147
|
+
|
|
148
|
+
if below is not None:
|
|
149
|
+
bg = np.asarray(below) > bg_frac
|
|
150
|
+
uv = np.where(bg, np.nan, uv)
|
|
151
|
+
vth = np.where(bg, np.nan, vth)
|
|
152
|
+
|
|
153
|
+
fphi = -A_EARTH * cosp * uv
|
|
154
|
+
fp = A_EARTH * cosp * f_cor * vth / dthdp
|
|
155
|
+
|
|
156
|
+
cosp_safe = np.clip(cosp, np.cos(np.deg2rad(85.0)), None)
|
|
157
|
+
div = np.gradient(fphi * cosp, latr, axis=-1) / (A_EARTH * cosp_safe) + np.gradient(fp, lnp, axis=-2) / pc
|
|
158
|
+
force = div / (A_EARTH * cosp_safe) * 86400.0
|
|
159
|
+
force[..., np.abs(lat) > polar_mask_deg] = np.nan # before smoothing: nothing leaks in from the pole
|
|
160
|
+
if smooth_deg and smooth_deg > 0:
|
|
161
|
+
force = _nan_smooth_lat(force, smooth_deg / float(np.abs(np.diff(lat)).mean()))
|
|
162
|
+
return EPFlux(fphi, fp, force, ubar, lat, plev, below)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def ep_flux(
|
|
166
|
+
u: np.ndarray,
|
|
167
|
+
v: np.ndarray,
|
|
168
|
+
t: np.ndarray,
|
|
169
|
+
lat: np.ndarray,
|
|
170
|
+
plev: np.ndarray,
|
|
171
|
+
kmax: int = 3,
|
|
172
|
+
polar_mask_deg: float = 82.0,
|
|
173
|
+
theta_p: str = "zonal",
|
|
174
|
+
psfc: np.ndarray | None = None,
|
|
175
|
+
bg_frac: float = 0.10,
|
|
176
|
+
smooth_deg: float = 0.0,
|
|
177
|
+
) -> EPFlux:
|
|
178
|
+
"""QG E-P flux of zonal wavenumbers 1..kmax from (..., plev, lat, lon)
|
|
179
|
+
snapshots of u, v, T (leading axes such as members or leads are carried
|
|
180
|
+
through: one flux per member). plev in hPa (monotonic), lat in degrees
|
|
181
|
+
(any order); returns fields on the input (..., plev, lat) grid.
|
|
182
|
+
|
|
183
|
+
theta_p : "zonal" (default) or "global" static stability (see module notes)
|
|
184
|
+
psfc : surface pressure (..., lat, lon) in Pa -> below-ground masking
|
|
185
|
+
smooth_deg: NaN-aware Gaussian smoothing of the force along latitude only
|
|
186
|
+
(degrees; 0 = none, the v0.1 behaviour). The polar rows are
|
|
187
|
+
blanked first, so nothing leaks in from the pole."""
|
|
188
|
+
uv, vth, thbar, ubar = _quadratics(np.asarray(u, float), np.asarray(v, float), np.asarray(t, float), plev, kmax)
|
|
189
|
+
below = below_ground_share(psfc, plev) if psfc is not None else None
|
|
190
|
+
return _assemble(uv, vth, thbar, ubar, lat, plev, polar_mask_deg, theta_p, below, bg_frac, smooth_deg)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def ensemble_ep_flux(
|
|
194
|
+
members,
|
|
195
|
+
lat: np.ndarray,
|
|
196
|
+
plev: np.ndarray,
|
|
197
|
+
kmax: int = 3,
|
|
198
|
+
polar_mask_deg: float = 82.0,
|
|
199
|
+
theta_p: str = "zonal",
|
|
200
|
+
bg_frac: float = 0.10,
|
|
201
|
+
smooth_deg: float = 0.0,
|
|
202
|
+
) -> EPFlux:
|
|
203
|
+
"""Ensemble E-P flux: average the per-member QUADRATICS (equivalently the
|
|
204
|
+
per-member fluxes -- both are linear in [u'v'], [v'theta']), then form the
|
|
205
|
+
flux and divergence once. `members` iterates (u, v, t) triples of
|
|
206
|
+
(plev, lat, lon) arrays, or (u, v, t, psfc) quadruples for below-ground
|
|
207
|
+
masking (the member-mean below-ground share is compared with bg_frac);
|
|
208
|
+
memory stays O(one member)."""
|
|
209
|
+
n = 0
|
|
210
|
+
uv_s = vth_s = th_s = ub_s = bg_s = None
|
|
211
|
+
for mem in members:
|
|
212
|
+
u, v, t = mem[:3]
|
|
213
|
+
psfc = mem[3] if len(mem) > 3 else None
|
|
214
|
+
uv, vth, thbar, ubar = _quadratics(np.asarray(u, float), np.asarray(v, float), np.asarray(t, float), plev, kmax)
|
|
215
|
+
bg = below_ground_share(psfc, plev) if psfc is not None else None
|
|
216
|
+
if n == 0:
|
|
217
|
+
uv_s, vth_s, th_s, ub_s, bg_s = uv, vth, thbar, ubar, bg
|
|
218
|
+
else:
|
|
219
|
+
uv_s += uv
|
|
220
|
+
vth_s += vth
|
|
221
|
+
th_s += thbar
|
|
222
|
+
ub_s += ubar
|
|
223
|
+
if bg is not None and bg_s is not None:
|
|
224
|
+
bg_s = bg_s + bg
|
|
225
|
+
n += 1
|
|
226
|
+
if n == 0:
|
|
227
|
+
raise ValueError("no members supplied")
|
|
228
|
+
return _assemble(
|
|
229
|
+
uv_s / n,
|
|
230
|
+
vth_s / n,
|
|
231
|
+
th_s / n,
|
|
232
|
+
ub_s / n,
|
|
233
|
+
lat,
|
|
234
|
+
plev,
|
|
235
|
+
polar_mask_deg,
|
|
236
|
+
theta_p,
|
|
237
|
+
None if bg_s is None else bg_s / n,
|
|
238
|
+
bg_frac,
|
|
239
|
+
smooth_deg,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def charney_drazin_uc(
|
|
244
|
+
lat: np.ndarray, plev: np.ndarray, th_prof: np.ndarray, k: int = 1, H: float = H_SCALE
|
|
245
|
+
) -> np.ndarray:
|
|
246
|
+
"""Charney-Drazin critical velocity U_c (m/s) on (..., plev, lat) for a
|
|
247
|
+
stationary zonal wavenumber k, plane-wave form:
|
|
248
|
+
|
|
249
|
+
U_c = beta / [ (k / (a cos phi))^2 + l^2 + f^2 / (4 N^2 H^2) ]
|
|
250
|
+
|
|
251
|
+
with the standard meridional scale l = 2/a (which puts U_c(60 deg) near
|
|
252
|
+
28 m/s for a stratospheric N^2 = 4e-4 s^-2). N^2 = (g/theta) d theta/dz
|
|
253
|
+
from the (..., plev) potential-temperature profile th_prof (a global or
|
|
254
|
+
band mean -- the QG reference profile), z = -H ln(p/1000 hPa), floored
|
|
255
|
+
at 5e-6 s^-2. cos(phi) is floored at 5e-3."""
|
|
256
|
+
lat = np.asarray(lat, float)
|
|
257
|
+
plev = np.asarray(plev, float)
|
|
258
|
+
th = np.asarray(th_prof, float)
|
|
259
|
+
latr = np.deg2rad(lat)
|
|
260
|
+
cosp = np.clip(np.cos(latr), 5e-3, None)
|
|
261
|
+
f = 2 * OMEGA * np.sin(latr)
|
|
262
|
+
beta = 2 * OMEGA * cosp / A_EARTH
|
|
263
|
+
z = -H * np.log(plev / P_REF)
|
|
264
|
+
N2 = np.clip(G / th * np.gradient(th, z, axis=-1), 5e-6, None)[..., None]
|
|
265
|
+
return beta / ((k / (A_EARTH * cosp)) ** 2 + (2.0 / A_EARTH) ** 2 + f**2 / (4.0 * N2 * H**2))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def charney_drazin_excess(
|
|
269
|
+
U: np.ndarray,
|
|
270
|
+
lat: np.ndarray,
|
|
271
|
+
plev: np.ndarray,
|
|
272
|
+
th_prof: np.ndarray,
|
|
273
|
+
k: int = 1,
|
|
274
|
+
H: float = H_SCALE,
|
|
275
|
+
smooth_deg: float = 2.0,
|
|
276
|
+
lat_min: float = 20.0,
|
|
277
|
+
lat_max: float = 82.0,
|
|
278
|
+
p_max: float = 400.0,
|
|
279
|
+
) -> np.ndarray:
|
|
280
|
+
"""u - U_c (m/s) on (..., plev, lat): the Charney-Drazin propagation
|
|
281
|
+
ceiling for stationary wavenumber k. Its zero contour is the ceiling;
|
|
282
|
+
with the u = 0 line it brackets the corridor 0 < u < U_c.
|
|
283
|
+
|
|
284
|
+
U : zonal-mean zonal wind (..., plev, lat); leading axes (members,
|
|
285
|
+
leads) are carried through untouched.
|
|
286
|
+
th_prof : (plev,) or (..., plev) reference potential temperature.
|
|
287
|
+
smooth_deg : Gaussian smoothing of U in LATITUDE only (sigma in degrees;
|
|
288
|
+
0 = none) -- applied on the last axis, never a member axis.
|
|
289
|
+
Masked (NaN) at |lat| < lat_min (QG invalid), |lat| > lat_max (beta -> 0,
|
|
290
|
+
U_c collapses) and p > p_max hPa.
|
|
291
|
+
|
|
292
|
+
The plane-wave form was chosen over the full Matsuno refractive index
|
|
293
|
+
n^2 = 0 after validation on the site: a strong jet sharpens its own PV
|
|
294
|
+
gradient, so the full index stays positive over the jet core and its
|
|
295
|
+
zero line marks flank reflecting pockets, not the lid."""
|
|
296
|
+
lat = np.asarray(lat, float)
|
|
297
|
+
plev = np.asarray(plev, float)
|
|
298
|
+
U = np.asarray(U, float)
|
|
299
|
+
if smooth_deg and smooth_deg > 0:
|
|
300
|
+
from scipy.ndimage import gaussian_filter1d
|
|
301
|
+
|
|
302
|
+
dlat = abs(float(np.median(np.diff(lat))))
|
|
303
|
+
U = gaussian_filter1d(U, sigma=smooth_deg / dlat, axis=-1, mode="nearest")
|
|
304
|
+
out = U - charney_drazin_uc(lat, plev, th_prof, k=k, H=H)
|
|
305
|
+
out = np.where((np.abs(lat) < lat_min) | (np.abs(lat) > lat_max), np.nan, out)
|
|
306
|
+
return np.where((plev > p_max)[:, None], np.nan, out)
|
quantmet/gill.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Gill (1980) steady tropical response to heating: a linear, damped
|
|
2
|
+
shallow-water model on the equatorial beta-plane, solved directly.
|
|
3
|
+
|
|
4
|
+
eps u - y v + p_x = 0
|
|
5
|
+
eps v + y u + p_y = 0 (nondimensional; Gill 1980, QJRMS)
|
|
6
|
+
eps p + u_x + v_y = -Q
|
|
7
|
+
|
|
8
|
+
x, y are scaled by the equatorial radius of deformation L = sqrt(c / beta),
|
|
9
|
+
time by T = 1 / sqrt(c beta), velocity by c and p (a geopotential) by c^2.
|
|
10
|
+
(u, v, p) are the LOWER-tropospheric fields of the first baroclinic mode: a
|
|
11
|
+
positive heating Q gives low-level convergence and low pressure; the upper-
|
|
12
|
+
tropospheric wind is the negative of u, v. Rayleigh friction and Newtonian
|
|
13
|
+
cooling share one rate eps (Gill's choice; eps = 0.1 with c = 30 m/s is a
|
|
14
|
+
~4.4-day damping).
|
|
15
|
+
|
|
16
|
+
Numerical notes that matter in practice:
|
|
17
|
+
- DIRECT SOLVE, not time-stepping. The steady problem is one sparse linear
|
|
18
|
+
system (3 N unknowns, centred differences on a collocated lat-lon grid,
|
|
19
|
+
periodic in longitude, v = 0 at the two latitude walls), handed to
|
|
20
|
+
scipy.sparse.linalg.spsolve. An explicitly time-stepped version was
|
|
21
|
+
unstable: the Coriolis term y*u grows with |y|, and at the poleward rows
|
|
22
|
+
of a +-30 deg domain it exceeds the stable step of any reasonable dt. The
|
|
23
|
+
direct solve has no step, no spin-up and no convergence test; it is the
|
|
24
|
+
exact steady state of the discrete equations.
|
|
25
|
+
- SPONGE. Damping rises linearly from eps to eps + sponge_damp between
|
|
26
|
+
sponge_lat and sponge_lat + sponge_width degrees, so Rossby waves are
|
|
27
|
+
absorbed before the walls instead of reflecting off them.
|
|
28
|
+
- CHECKERBOARD CONTROL. Centred differences on a collocated grid admit a
|
|
29
|
+
2-dx mode that the pressure gradient cannot see; a weak del^4 term in x
|
|
30
|
+
(coefficient `smooth`, in GRID units, eigenvalue 16*smooth on the 2-dx
|
|
31
|
+
mode) removes it. At smooth = 0.02 a wave resolved by 20 points is damped
|
|
32
|
+
at a rate of ~1e-4, far below eps. Because it is in grid units, halving
|
|
33
|
+
the grid spacing weakens it 16x at a fixed physical scale.
|
|
34
|
+
- PERIODIC WRAP AT SMALL eps. Longitude is periodic, so the forced Kelvin
|
|
35
|
+
wave travels round the equator and re-enters from the west, on top of
|
|
36
|
+
the Rossby response there. Its e-folding length is 1/eps deformation
|
|
37
|
+
radii (about 11,500 km at eps = 0.1, c = 30 m/s) against a circumference
|
|
38
|
+
of ~35 radii (2 pi a / L), so the wrapped Kelvin wave arrives at
|
|
39
|
+
exp(-35 eps) of its amplitude -- 3 % at eps = 0.1 -- where the Rossby
|
|
40
|
+
signal has itself decayed: the measured westward decay rate 1-4 radii
|
|
41
|
+
west of the heating is 20 % slow at eps = 0.1, 4-5 % at eps = 0.2 and
|
|
42
|
+
~2 % at eps = 0.3. It is the wrap, not the walls: moving the walls from
|
|
43
|
+
+-30 to +-50 deg changes nothing, while shrinking L (c = 10 or 5 m/s,
|
|
44
|
+
circumference 60 or 85 radii) cuts the eps = 0.1 error to 6 and 4 %.
|
|
45
|
+
Quantitative decay-rate work wants eps >= 0.2 (or a smaller c); the
|
|
46
|
+
pattern of the response is robust either way.
|
|
47
|
+
- TOTAL-SST HEATING (heating_from_ssta). Heating is proportional to the SST
|
|
48
|
+
anomaly only where the TOTAL SST (climatology + anomaly) is warm enough to
|
|
49
|
+
support deep convection, tapered over 26-28 C. Thresholding on the
|
|
50
|
+
climatology instead leaves a warm eastern-Pacific cold tongue unheated;
|
|
51
|
+
on the site's Walker product this one choice took the Gill El Nino
|
|
52
|
+
response from about half the regressed amplitude to about 70 %.
|
|
53
|
+
|
|
54
|
+
Analytic checks (tests/test_gill.py), for Gill's heating exp(-y^2/4) F(x),
|
|
55
|
+
which projects only on the Kelvin wave and the n = 1 Rossby wave: east of
|
|
56
|
+
the heating the equatorial p decays as exp(-eps x) (the damped Kelvin wave,
|
|
57
|
+
exactly);
|
|
58
|
+
west of it the n = 1 Rossby wave decays as exp(kappa x) with
|
|
59
|
+
kappa = (sqrt(1/eps^2 + 4 (3 + eps^2)) - 1/eps) / 2 -- the damped
|
|
60
|
+
dispersion relation WITHOUT the long-wave approximation, because the model
|
|
61
|
+
keeps eps v. (The long-wave value 3 eps is 0.6 at eps = 0.2; the exact root
|
|
62
|
+
is 0.548.) For heating symmetric about the equator u and p are symmetric
|
|
63
|
+
and v antisymmetric.
|
|
64
|
+
|
|
65
|
+
Powers the idealized leg of the Walker-circulation-by-basin product at
|
|
66
|
+
https://scorvec.com/circulation.html.
|
|
67
|
+
|
|
68
|
+
Requires: numpy, scipy.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
from __future__ import annotations
|
|
72
|
+
|
|
73
|
+
import numpy as np
|
|
74
|
+
|
|
75
|
+
from .constants import A_EARTH, G, OMEGA
|
|
76
|
+
|
|
77
|
+
C_WAVE = 30.0 # m/s: first baroclinic mode, moist-reduced
|
|
78
|
+
BETA = 2.0 * OMEGA / A_EARTH # 1/(m s), 2.289e-11
|
|
79
|
+
EPS = 0.1 # nondimensional damping (Gill 1980)
|
|
80
|
+
L_V = 2.5e6 # J/kg, latent heat of condensation
|
|
81
|
+
C_P = 1004.0 # J/(kg K)
|
|
82
|
+
W_PER_MM_DAY = L_V / 86400.0 # 1 mm/day of rain = 28.9 W/m^2 of column heating
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"gill_scales",
|
|
86
|
+
"gill_response",
|
|
87
|
+
"rossby_decay_rate",
|
|
88
|
+
"heating_from_ssta",
|
|
89
|
+
"column_heating",
|
|
90
|
+
"heating_from_precip",
|
|
91
|
+
"to_dimensional",
|
|
92
|
+
"band_mean",
|
|
93
|
+
"C_WAVE",
|
|
94
|
+
"BETA",
|
|
95
|
+
"EPS",
|
|
96
|
+
"W_PER_MM_DAY",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def gill_scales(c: float = C_WAVE, beta: float = BETA):
|
|
101
|
+
"""(L, T): equatorial deformation radius (m) and time scale (s)."""
|
|
102
|
+
return np.sqrt(c / beta), 1.0 / np.sqrt(c * beta)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def rossby_decay_rate(eps: float) -> float:
|
|
106
|
+
"""Westward e-folding rate (per deformation radius) of the steady damped
|
|
107
|
+
n = 1 Rossby response, exact (no long-wave approximation). With damping
|
|
108
|
+
eps on every equation the dispersion relation w^2 - k^2 - k/w = 3 at
|
|
109
|
+
w = i eps and k = i kappa gives kappa^2 - kappa/eps - (3 + eps^2) = 0;
|
|
110
|
+
the westward-decaying root is returned as a positive rate."""
|
|
111
|
+
return 0.5 * (np.sqrt(1.0 / eps**2 + 4.0 * (3.0 + eps**2)) - 1.0 / eps)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _periodic_laplacian(n: int):
|
|
115
|
+
import scipy.sparse as sp
|
|
116
|
+
|
|
117
|
+
L = sp.diags([np.ones(n - 1), -2 * np.ones(n), np.ones(n - 1)], [-1, 0, 1], shape=(n, n)).tolil()
|
|
118
|
+
L[0, n - 1] = 1
|
|
119
|
+
L[n - 1, 0] = 1
|
|
120
|
+
return L.tocsr()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def gill_response(
|
|
124
|
+
Q: np.ndarray,
|
|
125
|
+
lat: np.ndarray,
|
|
126
|
+
lon: np.ndarray,
|
|
127
|
+
eps: float = EPS,
|
|
128
|
+
c: float = C_WAVE,
|
|
129
|
+
beta: float = BETA,
|
|
130
|
+
sponge_lat: float = 24.0,
|
|
131
|
+
sponge_width: float = 6.0,
|
|
132
|
+
sponge_damp: float = 0.6,
|
|
133
|
+
smooth: float = 0.02,
|
|
134
|
+
):
|
|
135
|
+
"""Steady (u, v, p) for heating Q(lat, lon), all nondimensional.
|
|
136
|
+
|
|
137
|
+
lat : ascending, uniformly spaced degrees (the walls are its first and
|
|
138
|
+
last rows); lon : uniformly spaced degrees covering the full circle
|
|
139
|
+
(periodic). Q is in the model's units (see heating_from_precip for
|
|
140
|
+
a physical scaling; heating_from_ssta gives a shape only).
|
|
141
|
+
eps : damping; c, beta : set the length scale L = sqrt(c/beta) (the
|
|
142
|
+
nondimensional answer depends on them only through the grid).
|
|
143
|
+
sponge_lat, sponge_width, sponge_damp : extra damping ramped linearly
|
|
144
|
+
from 0 at |lat| = sponge_lat to sponge_damp at sponge_lat +
|
|
145
|
+
sponge_width (and beyond).
|
|
146
|
+
smooth : del^4 checkerboard control in x, in grid units (0 disables).
|
|
147
|
+
|
|
148
|
+
Returns u, v, p with Q's shape. Multiply by to_dimensional for m/s and
|
|
149
|
+
m^2/s^2."""
|
|
150
|
+
import scipy.sparse as sp
|
|
151
|
+
import scipy.sparse.linalg as spla
|
|
152
|
+
|
|
153
|
+
lat = np.asarray(lat, float)
|
|
154
|
+
lon = np.asarray(lon, float)
|
|
155
|
+
Q = np.nan_to_num(np.asarray(Q, float))
|
|
156
|
+
ny, nx = Q.shape
|
|
157
|
+
if (ny, nx) != (lat.size, lon.size):
|
|
158
|
+
raise ValueError("Q must be (lat, lon)")
|
|
159
|
+
if np.any(np.diff(lat) <= 0):
|
|
160
|
+
raise ValueError("lat must be ascending")
|
|
161
|
+
dlon = np.diff(lon)
|
|
162
|
+
if not np.allclose(dlon, dlon[0]) or not np.isclose(nx * dlon[0], 360.0):
|
|
163
|
+
raise ValueError("lon must be uniform and cover 360 degrees (periodic)")
|
|
164
|
+
Lg, _ = gill_scales(c, beta)
|
|
165
|
+
y = np.deg2rad(lat) * A_EARTH / Lg
|
|
166
|
+
x = np.deg2rad(lon) * A_EARTH / Lg
|
|
167
|
+
dx = x[1] - x[0]
|
|
168
|
+
dy = y[1] - y[0]
|
|
169
|
+
N = ny * nx
|
|
170
|
+
damp = eps + sponge_damp * np.clip((np.abs(lat) - sponge_lat) / sponge_width, 0, 1)
|
|
171
|
+
|
|
172
|
+
Dx1 = sp.diags([np.ones(nx - 1), -np.ones(nx - 1)], [1, -1], shape=(nx, nx)).tolil()
|
|
173
|
+
Dx1[0, nx - 1] = -1
|
|
174
|
+
Dx1[nx - 1, 0] = 1
|
|
175
|
+
Dx = sp.kron(sp.identity(ny), Dx1.tocsr() / (2 * dx), format="csr")
|
|
176
|
+
Dy1 = sp.diags([np.ones(ny - 1), -np.ones(ny - 1)], [1, -1], shape=(ny, ny)).tolil() / (2 * dy)
|
|
177
|
+
Dy1[0, 0] = -1 / dy
|
|
178
|
+
Dy1[0, 1] = 1 / dy
|
|
179
|
+
Dy1[ny - 1, ny - 1] = 1 / dy
|
|
180
|
+
Dy1[ny - 1, ny - 2] = -1 / dy
|
|
181
|
+
Dy = sp.kron(Dy1.tocsr(), sp.identity(nx), format="csr")
|
|
182
|
+
Yd = sp.diags(np.repeat(y, nx))
|
|
183
|
+
Ed = sp.diags(np.repeat(damp, nx))
|
|
184
|
+
Lx = sp.kron(sp.identity(ny), _periodic_laplacian(nx), format="csr")
|
|
185
|
+
S = smooth * (Lx @ Lx)
|
|
186
|
+
A = sp.bmat([[Ed + S, -Yd, Dx], [Yd, Ed + S, Dy], [Dx, Dy, Ed + S]], format="lil")
|
|
187
|
+
b = np.concatenate([np.zeros(N), np.zeros(N), -Q.ravel()])
|
|
188
|
+
for j in (0, ny - 1): # walls: v = 0
|
|
189
|
+
for i in range(nx):
|
|
190
|
+
r = N + j * nx + i
|
|
191
|
+
A.rows[r] = [r]
|
|
192
|
+
A.data[r] = [1.0]
|
|
193
|
+
b[r] = 0.0
|
|
194
|
+
sol = spla.spsolve(A.tocsc(), b)
|
|
195
|
+
return (sol[:N].reshape(ny, nx), sol[N : 2 * N].reshape(ny, nx), sol[2 * N :].reshape(ny, nx))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def to_dimensional(u, v, p, c: float = C_WAVE):
|
|
199
|
+
"""Nondimensional (u, v, p) -> (m/s, m/s, m^2/s^2): velocity scale c,
|
|
200
|
+
geopotential scale c^2."""
|
|
201
|
+
return np.asarray(u) * c, np.asarray(v) * c, np.asarray(p) * c * c
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def heating_from_ssta(
|
|
205
|
+
ssta: np.ndarray,
|
|
206
|
+
sst_clim: np.ndarray,
|
|
207
|
+
lat: np.ndarray,
|
|
208
|
+
t_lo: float = 26.0,
|
|
209
|
+
t_hi: float = 28.0,
|
|
210
|
+
lat_edge: float = 22.0,
|
|
211
|
+
lat_taper: float = 4.0,
|
|
212
|
+
smooth: bool = True,
|
|
213
|
+
) -> np.ndarray:
|
|
214
|
+
"""Heating SHAPE (units of the SST anomaly, K) on (lat, lon): the SST
|
|
215
|
+
anomaly where the TOTAL SST (sst_clim + ssta, deg C) supports deep
|
|
216
|
+
convection, ramped from 0 at t_lo to 1 at t_hi so there is no cliff;
|
|
217
|
+
confined equatorward of lat_edge with a lat_taper-degree ramp; and, with
|
|
218
|
+
`smooth`, a 1-2-1 filter in longitude (periodic). Using the total rather
|
|
219
|
+
than the climatological SST lets a warm cold tongue convect -- the
|
|
220
|
+
nonlinearity that makes a strong El Nino's response more than a scaled
|
|
221
|
+
weak one. The amplitude is arbitrary: calibrate it (the site matches the
|
|
222
|
+
ENSO regression) or use heating_from_precip for a physical scale."""
|
|
223
|
+
ssta = np.nan_to_num(np.asarray(ssta, float))
|
|
224
|
+
total = np.nan_to_num(np.asarray(sst_clim, float), nan=0.0) + ssta
|
|
225
|
+
taper = np.clip((total - t_lo) / (t_hi - t_lo), 0, 1)
|
|
226
|
+
latw = np.clip((lat_edge - np.abs(np.asarray(lat, float))) / lat_taper, 0, 1)[:, None]
|
|
227
|
+
Q = ssta * taper * latw
|
|
228
|
+
if smooth:
|
|
229
|
+
Q = (Q + 0.5 * (np.roll(Q, 1, 1) + np.roll(Q, -1, 1))) / 2.0
|
|
230
|
+
return Q
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def column_heating(p_mm_day):
|
|
234
|
+
"""Column-integrated latent heating (W/m^2) of a rain rate (mm/day):
|
|
235
|
+
L_v * P, i.e. 28.9 W/m^2 per mm/day. Over a 1000-100 hPa column that is
|
|
236
|
+
a mass-weighted mean heating of 0.27 K/day per mm/day."""
|
|
237
|
+
return np.asarray(p_mm_day, float) * W_PER_MM_DAY
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def heating_from_precip(
|
|
241
|
+
p_anom_mm_day,
|
|
242
|
+
c: float = C_WAVE,
|
|
243
|
+
beta: float = BETA,
|
|
244
|
+
depth: float = 15e3,
|
|
245
|
+
dp: float = 9.0e4,
|
|
246
|
+
t_ref: float = 300.0,
|
|
247
|
+
mode_factor: float = 1.0,
|
|
248
|
+
):
|
|
249
|
+
"""Gill heating Q (nondimensional, for gill_response) from a rain-rate
|
|
250
|
+
anomaly (mm/day), by the latent-heat conversion and a first-baroclinic
|
|
251
|
+
projection. Linear: Q = K * mode_factor * P with K ~ 0.033 per mm/day at
|
|
252
|
+
the defaults.
|
|
253
|
+
|
|
254
|
+
Derivation (documented so the scale can be checked or replaced):
|
|
255
|
+
1. Column heating H = L_v P = 28.9 W/m^2 per mm/day (column_heating).
|
|
256
|
+
2. Mass-weighted mean heating rate over the heated column of pressure
|
|
257
|
+
depth dp: Tdot_mean = g H / (c_p dp) (K/s; 0.27 K/day per mm/day
|
|
258
|
+
for dp = 900 hPa).
|
|
259
|
+
3. First baroclinic mode of a troposphere of height `depth`: heating
|
|
260
|
+
sin(pi z/depth) with peak (pi/2) Tdot_mean, geopotential
|
|
261
|
+
cos(pi z/depth). Projecting the thermodynamic equation
|
|
262
|
+
d/dt(dphi/dz) + N^2 w = (g/t_ref) Tdot gives the shallow-water mass
|
|
263
|
+
equation dphi/dt + c^2 div(u) = -Q_d with c = N depth / pi and
|
|
264
|
+
Q_d = g depth Tdot_mean / (2 t_ref) (m^2/s^3).
|
|
265
|
+
4. Nondimensionalize with velocity c, length L = sqrt(c/beta), time
|
|
266
|
+
T = 1/sqrt(c beta): Q = Q_d T / c^2.
|
|
267
|
+
`mode_factor` is the fraction of the heating that projects on this mode:
|
|
268
|
+
1 for the deep, sine-shaped profile of convective rain; less for
|
|
269
|
+
stratiform or bottom-heavy heating (and part of that projection feeds
|
|
270
|
+
higher modes the model does not carry). The same c sets both the length
|
|
271
|
+
scale and the conversion, so choose it once for both calls. This is an
|
|
272
|
+
order-of-magnitude physical scale, not a calibration; the site's Walker
|
|
273
|
+
product calibrates amplitude against observed regressions."""
|
|
274
|
+
_, T = gill_scales(c, beta)
|
|
275
|
+
tdot_mean = G * column_heating(p_anom_mm_day) / (C_P * dp)
|
|
276
|
+
Q_d = G * depth * tdot_mean / (2.0 * t_ref)
|
|
277
|
+
return mode_factor * Q_d * T / (c * c)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def band_mean(f: np.ndarray, lat: np.ndarray, half: float = 5.0) -> np.ndarray:
|
|
281
|
+
"""cos(lat)-weighted mean of f(lat, lon) over |lat| <= half: the
|
|
282
|
+
equatorial-band profile used for Walker-cell indices."""
|
|
283
|
+
lat = np.asarray(lat, float)
|
|
284
|
+
m = np.abs(lat) <= half
|
|
285
|
+
w = np.cos(np.deg2rad(lat[m]))
|
|
286
|
+
return (np.asarray(f)[m] * w[:, None]).sum(0) / w.sum()
|