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