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/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""quantmet: quantitative meteorology tools extracted from the real-time
|
|
2
|
+
diagnostics pipelines behind scorvec.com.
|
|
3
|
+
|
|
4
|
+
Modules are imported individually (``from quantmet.tem import tem_terms``);
|
|
5
|
+
importing the package itself pulls in nothing beyond the version, so the
|
|
6
|
+
optional dependencies of one module (pyshtools, xarray for helmholtz and the
|
|
7
|
+
streamfunction path of waf) never burden another.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "0.4.4" # the only place the version is set (pyproject.toml reads it)
|
quantmet/conservation.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Global conservation diagnostics for gridded model output: dry-air mass, the column water budget, column total
|
|
2
|
+
energy, and atmospheric angular momentum (AAM) with its mountain and friction torques.
|
|
3
|
+
|
|
4
|
+
Used to test whether data-driven forecast models conserve what the equations do (dry mass, water, energy, angular
|
|
5
|
+
momentum), against a physics model and a reanalysis: https://scorvec.com/aifs-aam-budget.html.
|
|
6
|
+
|
|
7
|
+
Grids: regular latitude-longitude, global; latitude in either order; integration weights are cos(phi) dlam dphi
|
|
8
|
+
(midpoint rule), which on a regular grid that includes the poles gives the pole rows zero weight, as it should.
|
|
9
|
+
|
|
10
|
+
Vertical integration on pressure levels: layer edges halfway between levels, the top edge at 0 hPa and the bottom
|
|
11
|
+
edge at 1100 hPa, then CLIPPED to the surface pressure p_s, so each column integrates exactly 0..p_s:
|
|
12
|
+
dp_k = clip(min(edge_k+1, p_s) - min(edge_k, p_s), 0, inf).
|
|
13
|
+
Levels below the ground therefore get zero (or partial) weight -- the values a model extrapolates there never enter.
|
|
14
|
+
Stopping the top at the highest level instead (a common shortcut) drops the mass above it: 50 hPa of a 1000 hPa
|
|
15
|
+
column is 5 % of the mass.
|
|
16
|
+
|
|
17
|
+
Formulas (g = 9.80665, a = 6.371e6, Omega = 7.292115e-5):
|
|
18
|
+
dry-air pressure p_d = p_s - g * TCW (TCW: total column water, vapour + condensate, kg/m2)
|
|
19
|
+
dry-air mass M_d = (a^2/g) sum p_d cos(phi) dlam dphi (kg; ~5.135e18)
|
|
20
|
+
column water budget dW/dt = E - P (no horizontal term globally) -> implied E = P + dW/dt
|
|
21
|
+
column total energy E = int (c_v T + Phi + L_v q + k) dp/g (internal + potential + latent + kinetic, J/m2)
|
|
22
|
+
= int (c_p T + L_v q + k) dp/g + Phi_s p_s / g (the same, for a hydrostatic column)
|
|
23
|
+
(k = (u^2+v^2)/2, ~0.05 % of the total). NOT int (c_p T + Phi + L_v q) dp/g: integrating
|
|
24
|
+
p dPhi by parts gives int Phi dp/g = Phi_s p_s/g + int R T dp/g, so adding Phi to c_p T counts
|
|
25
|
+
the pressure work R T twice (+28 % of the total; quantmet <= 0.3.0 did this, fixed in 0.3.1).
|
|
26
|
+
relative AAM M_r = (a^3/g) sum u cos^2(phi) dp dlam dphi (kg m2/s; 1 "Hadley" unit = 1e18)
|
|
27
|
+
mass (Omega) AAM M_O = (Omega a^4/g) sum p_s cos^3(phi) dlam dphi
|
|
28
|
+
mountain torque T_m = a^2 sum h (dp_s/dlam) cos(phi) dlam dphi (= -sum p_s dh/dlam dA; N m, + = adds
|
|
29
|
+
westerly AAM to the atmosphere)
|
|
30
|
+
friction torque T_f = -a^3 sum tau_x cos^2(phi) dlam dphi (tau_x: surface stress ON the surface, i.e.
|
|
31
|
+
the same sign as the wind)
|
|
32
|
+
Hemispheric splits give the equator row half to each hemisphere, so NH + SH = global exactly.
|
|
33
|
+
|
|
34
|
+
References: Trenberth & Smith (2005, J. Climate) on dry mass; Trenberth (1997, J. Climate 10) and Peixoto & Oort
|
|
35
|
+
(1992, ch. 13) on column energy; Egger, Weickmann & Hoinka (2007, Rev. Geophys.) on AAM and torques.
|
|
36
|
+
|
|
37
|
+
Requires: numpy only.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
|
|
44
|
+
from .constants import A_EARTH, G, OMEGA, RD
|
|
45
|
+
from .constants import CPD as CP
|
|
46
|
+
from .constants import LV0 as LV
|
|
47
|
+
|
|
48
|
+
CV = CP - RD
|
|
49
|
+
__all__ = [
|
|
50
|
+
"layer_thickness",
|
|
51
|
+
"area_weights",
|
|
52
|
+
"global_mean",
|
|
53
|
+
"global_integral",
|
|
54
|
+
"dry_air_pressure",
|
|
55
|
+
"dry_air_mass",
|
|
56
|
+
"implied_evaporation",
|
|
57
|
+
"column_energy",
|
|
58
|
+
"relative_aam",
|
|
59
|
+
"mass_aam",
|
|
60
|
+
"mountain_torque",
|
|
61
|
+
"friction_torque",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def layer_thickness(p_pa, ps, top_pa: float = 0.0, bottom_pa: float = 110000.0) -> np.ndarray:
|
|
66
|
+
"""Layer thickness dp (Pa) per level, (lev, ...ps.shape), with edges midway between levels, the outer edges at
|
|
67
|
+
top_pa / bottom_pa, all clipped to the surface pressure ps (Pa). Levels in any order (output in the input order)."""
|
|
68
|
+
p = np.asarray(p_pa, float)
|
|
69
|
+
o = np.argsort(p)
|
|
70
|
+
ps = np.asarray(ps, float)
|
|
71
|
+
q = p[o]
|
|
72
|
+
edges = np.empty(len(q) + 1)
|
|
73
|
+
edges[1:-1] = 0.5 * (q[1:] + q[:-1])
|
|
74
|
+
edges[0] = top_pa
|
|
75
|
+
edges[-1] = bottom_pa
|
|
76
|
+
sh = (-1,) + (1,) * ps.ndim
|
|
77
|
+
lo = np.minimum(edges[:-1].reshape(sh), ps[None])
|
|
78
|
+
hi = np.minimum(edges[1:].reshape(sh), ps[None])
|
|
79
|
+
dp = np.clip(hi - lo, 0.0, None)
|
|
80
|
+
out = np.empty_like(dp)
|
|
81
|
+
out[o] = dp
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def area_weights(lat, lon) -> np.ndarray:
|
|
86
|
+
"""cos(phi) dlam dphi (sr) per cell of a regular global grid, (lat, lon); sums to ~4 pi."""
|
|
87
|
+
lat = np.asarray(lat, float)
|
|
88
|
+
lon = np.asarray(lon, float)
|
|
89
|
+
dlam = np.deg2rad(abs(lon[1] - lon[0]))
|
|
90
|
+
dphi = np.deg2rad(abs(lat[1] - lat[0]))
|
|
91
|
+
return np.cos(np.deg2rad(lat))[:, None] * dlam * dphi * np.ones(len(lon))[None, :]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _hemi(field_w, lat):
|
|
95
|
+
lat = np.asarray(lat, float)
|
|
96
|
+
row = field_w.sum(axis=-1)
|
|
97
|
+
eq = np.isclose(lat, 0.0)
|
|
98
|
+
wN = (lat > 0) + 0.5 * eq
|
|
99
|
+
wS = (lat < 0) + 0.5 * eq
|
|
100
|
+
return {"global": float(row.sum()), "nh": float((row * wN).sum()), "sh": float((row * wS).sum())}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def global_integral(f, lat, lon, hemispheres: bool = False):
|
|
104
|
+
"""sum f cos(phi) dlam dphi (per steradian -> multiply by a^2 for m^2)."""
|
|
105
|
+
fw = np.asarray(f, float) * area_weights(lat, lon)
|
|
106
|
+
return _hemi(fw, lat) if hemispheres else float(fw.sum())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def global_mean(f, lat, lon) -> float:
|
|
110
|
+
w = area_weights(lat, lon)
|
|
111
|
+
return float((np.asarray(f, float) * w).sum() / w.sum())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def dry_air_pressure(ps, tcw):
|
|
115
|
+
"""p_s - g * TCW (Pa); TCW in kg/m2 (vapour + condensate: use tcw, not tcwv, where both exist)."""
|
|
116
|
+
return np.asarray(ps, float) - G * np.asarray(tcw, float)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def dry_air_mass(ps, tcw, lat, lon, hemispheres: bool = False):
|
|
120
|
+
"""Dry-air mass (kg): (a^2/g) * integral of p_d."""
|
|
121
|
+
r = global_integral(dry_air_pressure(ps, tcw), lat, lon, hemispheres)
|
|
122
|
+
k = A_EARTH**2 / G
|
|
123
|
+
return {h: v * k for h, v in r.items()} if hemispheres else r * k
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def implied_evaporation(precip_mm, tcw_start, tcw_end, days: float = 1.0):
|
|
127
|
+
"""Global water budget E = P + dW/dt (no horizontal convergence globally), mm/day; precip_mm = precipitation over
|
|
128
|
+
the interval (mm = kg/m2), tcw in kg/m2, all global means (or any consistent area means)."""
|
|
129
|
+
return (np.asarray(precip_mm, float) + (np.asarray(tcw_end, float) - np.asarray(tcw_start, float))) / days
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def column_energy(t, phi, q, p_pa, ps, u=None, v=None, top_pa: float = 0.0, phis=None) -> dict:
|
|
133
|
+
"""Column-integrated energy (J/m2), each (lat, lon). Inputs (lev, lat, lon) on p_pa (Pa); ps (lat, lon) in Pa; phi
|
|
134
|
+
the geopotential on the levels and phis the SURFACE geopotential (orography x g), both m2/s2.
|
|
135
|
+
|
|
136
|
+
Components: 'cpT' (enthalpy), 'cvT' (internal energy), 'Phi' (int Phi dp/g, the potential energy), 'Lq', 'KE' (if
|
|
137
|
+
u and v are given), and 'PhiS' = Phi_s p_s / g (if phis is given).
|
|
138
|
+
|
|
139
|
+
'total' is the total energy I + P + LH + K:
|
|
140
|
+
with phis: cpT + Lq + KE + PhiS (Trenberth 1997 form; recommended: exact for any T profile,
|
|
141
|
+
no log-singular geopotential near the model top)
|
|
142
|
+
without: cvT + Phi + Lq + KE (Peixoto & Oort form; equal for a hydrostatic column, up to
|
|
143
|
+
the discretisation of int Phi dp across the top layer)
|
|
144
|
+
The identity: int_0^ps Phi dp = Phi_s p_s - int p dPhi = Phi_s p_s + int R T_v dp (hydrostatic dPhi = -R T_v dlnp),
|
|
145
|
+
so c_v T + Phi and c_p T integrate to the same thing apart from Phi_s p_s / g (and the virtual-temperature term
|
|
146
|
+
int R (T_v - T) dp/g, ~0.2 % of the total, which the moist geopotential carries and the c_p form leaves out).
|
|
147
|
+
|
|
148
|
+
Changed in 0.3.1: 'total' used to be cpT + Phi + Lq (+ KE), which counts the pressure work twice (+ int R T dp/g,
|
|
149
|
+
~28 % of the total). Its components are unchanged."""
|
|
150
|
+
dp = layer_thickness(p_pa, ps, top_pa=top_pa) / G # kg/m2 per layer
|
|
151
|
+
t = np.asarray(t, float)
|
|
152
|
+
out = {
|
|
153
|
+
"cpT": (CP * t * dp).sum(0),
|
|
154
|
+
"cvT": (CV * t * dp).sum(0),
|
|
155
|
+
"Phi": (np.asarray(phi, float) * dp).sum(0),
|
|
156
|
+
"Lq": (LV * np.asarray(q, float) * dp).sum(0),
|
|
157
|
+
}
|
|
158
|
+
ke = None
|
|
159
|
+
if u is not None and v is not None:
|
|
160
|
+
ke = out["KE"] = (0.5 * (np.asarray(u, float) ** 2 + np.asarray(v, float) ** 2) * dp).sum(0)
|
|
161
|
+
if phis is not None:
|
|
162
|
+
out["PhiS"] = np.asarray(phis, float) * np.asarray(ps, float) / G
|
|
163
|
+
out["total"] = out["cpT"] + out["Lq"] + out["PhiS"]
|
|
164
|
+
else:
|
|
165
|
+
out["total"] = out["cvT"] + out["Phi"] + out["Lq"]
|
|
166
|
+
if ke is not None:
|
|
167
|
+
out["total"] = out["total"] + ke
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def relative_aam(u, p_pa, ps, lat, lon, hemispheres: bool = False):
|
|
172
|
+
"""M_r (kg m2/s) from zonal wind u (lev, lat, lon) on p_pa (Pa), surface pressure ps (Pa)."""
|
|
173
|
+
lat = np.asarray(lat, float)
|
|
174
|
+
uint = (np.asarray(u, float) * layer_thickness(p_pa, ps)).sum(0) # (lat, lon), Pa m/s
|
|
175
|
+
f = (A_EARTH**3 / G) * uint * np.cos(np.deg2rad(lat))[:, None]
|
|
176
|
+
return global_integral(f, lat, lon, hemispheres)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def mass_aam(ps, lat, lon, hemispheres: bool = False):
|
|
180
|
+
"""M_O (kg m2/s): the angular momentum of the atmosphere's solid-body rotation with the Earth."""
|
|
181
|
+
lat = np.asarray(lat, float)
|
|
182
|
+
f = (OMEGA * A_EARTH**4 / G) * np.asarray(ps, float) * np.cos(np.deg2rad(lat))[:, None] ** 2
|
|
183
|
+
return global_integral(f, lat, lon, hemispheres)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def mountain_torque(ps, h, lat, lon, hemispheres: bool = False):
|
|
187
|
+
"""T_m (N m) = a^2 sum h dp_s/dlam cos(phi) dlam dphi; h surface height (m); periodic centred dlam that follows
|
|
188
|
+
the array's longitude direction."""
|
|
189
|
+
lon = np.asarray(lon, float)
|
|
190
|
+
ps = np.asarray(ps, float)
|
|
191
|
+
dlam = np.deg2rad(abs(lon[1] - lon[0]))
|
|
192
|
+
dpd = (np.roll(ps, -1, axis=-1) - np.roll(ps, 1, axis=-1)) / (2 * dlam) * np.sign(lon[1] - lon[0])
|
|
193
|
+
return global_integral(A_EARTH**2 * np.asarray(h, float) * dpd, lat, lon, hemispheres)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def friction_torque(tau_x, lat, lon, hemispheres: bool = False):
|
|
197
|
+
"""T_f (N m) = -a^3 sum tau_x cos^2(phi) dlam dphi; tau_x eastward surface stress (N/m2), same sign as the wind."""
|
|
198
|
+
lat = np.asarray(lat, float)
|
|
199
|
+
f = -(A_EARTH**3) * np.asarray(tau_x, float) * np.cos(np.deg2rad(lat))[:, None]
|
|
200
|
+
return global_integral(f, lat, lon, hemispheres)
|
quantmet/constants.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Physical constants shared across quantmet (SI units).
|
|
2
|
+
|
|
3
|
+
RD 287.04 J kg-1 K-1 gas constant of dry air
|
|
4
|
+
RV 461.5 J kg-1 K-1 gas constant of water vapour
|
|
5
|
+
EPSILON RD / RV = 0.62197 ratio of the molar masses of water and dry air
|
|
6
|
+
CPD 1004.7 J kg-1 K-1 specific heat of dry air at constant pressure
|
|
7
|
+
CPV 1846.0 J kg-1 K-1 specific heat of water vapour at constant pressure
|
|
8
|
+
CL 4218.0 J kg-1 K-1 specific heat of liquid water
|
|
9
|
+
CI 2106.0 J kg-1 K-1 specific heat of ice
|
|
10
|
+
LV0 2.501e6 J kg-1 latent heat of vaporisation at T0
|
|
11
|
+
LF0 3.337e5 J kg-1 latent heat of fusion at T0
|
|
12
|
+
LS0 LV0 + LF0 J kg-1 latent heat of sublimation at T0
|
|
13
|
+
G 9.80665 m s-2 standard gravity
|
|
14
|
+
T0 273.15 K 0 degC, the reference temperature of the latent heats
|
|
15
|
+
P0 1.0e5 Pa reference pressure of potential temperatures
|
|
16
|
+
KAPPA RD / CPD = 0.28570 Poisson exponent of dry air
|
|
17
|
+
A_EARTH 6.371e6 m mean Earth radius
|
|
18
|
+
OMEGA 7.292115e-5 s-1 Earth's rotation rate
|
|
19
|
+
|
|
20
|
+
With constant heat capacities the latent heats follow Kirchhoff's law,
|
|
21
|
+
L_v(T) = LV0 + (CPV - CL)(T - T0) (dL_v/dT = -2372 J kg-1 K-1, Bolton's 1980
|
|
22
|
+
value is -2370) and L_f(T) = LF0 + (CL - CI)(T - T0); see
|
|
23
|
+
``quantmet.thermo.latent_heat``.
|
|
24
|
+
|
|
25
|
+
Every module reads its physical constants from here (since 0.4.3; before that each carried its own, with Omega
|
|
26
|
+
between 7.292e-5 and 7.292115e-5, kappa 0.2854 in the E-P flux and 0.2857 elsewhere, and g 9.81 in the wave filter).
|
|
27
|
+
|
|
28
|
+
Formula-specific constants that are part of a published fit (Bolton's 0.2854,
|
|
29
|
+
the Magnus coefficients, Romps's 2017 constant set for the exact LCL) stay
|
|
30
|
+
with the formula in ``quantmet.thermo`` so that it reproduces the paper.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
RD = 287.04
|
|
36
|
+
RV = 461.5
|
|
37
|
+
EPSILON = RD / RV
|
|
38
|
+
CPD = 1004.7
|
|
39
|
+
CPV = 1846.0
|
|
40
|
+
CL = 4218.0
|
|
41
|
+
CI = 2106.0
|
|
42
|
+
LV0 = 2.501e6
|
|
43
|
+
LF0 = 3.337e5
|
|
44
|
+
LS0 = LV0 + LF0
|
|
45
|
+
G = 9.80665
|
|
46
|
+
T0 = 273.15
|
|
47
|
+
P0 = 1.0e5
|
|
48
|
+
KAPPA = RD / CPD
|
|
49
|
+
A_EARTH = 6.371e6
|
|
50
|
+
OMEGA = 7.292115e-5
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"RD",
|
|
54
|
+
"RV",
|
|
55
|
+
"EPSILON",
|
|
56
|
+
"CPD",
|
|
57
|
+
"CPV",
|
|
58
|
+
"CL",
|
|
59
|
+
"CI",
|
|
60
|
+
"LV0",
|
|
61
|
+
"LF0",
|
|
62
|
+
"LS0",
|
|
63
|
+
"G",
|
|
64
|
+
"T0",
|
|
65
|
+
"P0",
|
|
66
|
+
"KAPPA",
|
|
67
|
+
"A_EARTH",
|
|
68
|
+
"OMEGA",
|
|
69
|
+
]
|
quantmet/dct_spectra.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Kinetic-energy power spectra on limited-area grids via the 2-D Discrete
|
|
2
|
+
Cosine Transform (Denis, Cote & Laprise 2002, MWR).
|
|
3
|
+
|
|
4
|
+
The DCT sidesteps the periodicity assumption that corrupts FFT spectra on
|
|
5
|
+
regional domains: no detrending, no windowing, no spurious low-wavenumber
|
|
6
|
+
leakage from the domain-scale trend. Variance is binned into elliptic rings
|
|
7
|
+
of constant normalized wavenumber, giving a 1-D spectrum directly
|
|
8
|
+
comparable across models with different grid spacings.
|
|
9
|
+
|
|
10
|
+
Used in production to compare HRRR vs RRFS effective resolution
|
|
11
|
+
(https://scorvec.com): the spectral rolloff shows where each model's
|
|
12
|
+
numerics stop resolving the flow it advertises.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import scipy.fft
|
|
19
|
+
from scipy import stats
|
|
20
|
+
|
|
21
|
+
__all__ = ["ke_spectrum", "spectral_slope", "effective_resolution", "fidelity_resolution"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ke_spectrum(u: np.ndarray, v: np.ndarray, res_km: float):
|
|
25
|
+
"""1-D kinetic-energy spectrum from 2-D wind components.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
u, v : 2-D arrays (same shape), wind components on a uniform grid
|
|
30
|
+
res_km : grid spacing in km. The method assumes ONE spacing in both directions (a projected model grid). A
|
|
31
|
+
latitude-longitude box is not such a grid: dx = 111.2 cos(lat) dlon km shrinks poleward while dy stays
|
|
32
|
+
111.2 dlat km, so a single res_km mislabels the wavelengths of one direction (resample to a uniform grid
|
|
33
|
+
first if exact wavelengths matter).
|
|
34
|
+
|
|
35
|
+
Returns
|
|
36
|
+
-------
|
|
37
|
+
wavelengths : km, descending: res_km * min(shape) / k for k = 1, 2, ...
|
|
38
|
+
power : KE spectral density per wavenumber bin (an exact DCT mode with k wavelengths across the short side
|
|
39
|
+
of the domain lands in bin k)
|
|
40
|
+
"""
|
|
41
|
+
du = np.abs(scipy.fft.dctn(u, norm="ortho") / np.sqrt(u.size)) ** 2
|
|
42
|
+
dv = np.abs(scipy.fft.dctn(v, norm="ortho") / np.sqrt(v.size)) ** 2
|
|
43
|
+
p2 = 0.5 * (du + dv)
|
|
44
|
+
s0, s1 = p2.shape
|
|
45
|
+
nbwv, nbwx = min(s0, s1), max(s0, s1)
|
|
46
|
+
fr = np.arange(s0)[:, None] / s0
|
|
47
|
+
fc = np.arange(s1)[None, :] / s1
|
|
48
|
+
knrm = (np.sqrt(fr**2 + fc**2) * nbwv).flatten()
|
|
49
|
+
eps = 0.1 * np.sqrt(1 / nbwx)
|
|
50
|
+
kbins = np.arange(-1.0 + eps, nbwv + eps, 1.0)
|
|
51
|
+
abins, _, _ = stats.binned_statistic(knrm, p2.flatten(), statistic=np.nansum, bins=kbins)
|
|
52
|
+
p1 = abins[1:]
|
|
53
|
+
# adjacent-bin pairing: DCT index m is a HALF wavelength across the domain, so bins m = 2k-1 and 2k together hold
|
|
54
|
+
# full wavenumber k (k wavelengths across nbwv points). Pair k is p1[k-1], k = 1, 2, ... (0.3.0 and earlier
|
|
55
|
+
# labelled pair k with wavenumber k-1 and dropped k = 1: every wavelength one wavenumber too long, e.g. a 192 km
|
|
56
|
+
# mode reported at 256 km).
|
|
57
|
+
p1 = p1[0::2][: len(p1[1::2])] + p1[1::2]
|
|
58
|
+
wavenumbers = np.arange(1, len(p1) + 1)
|
|
59
|
+
wavelengths = res_km * nbwv / wavenumbers
|
|
60
|
+
return wavelengths, p1
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def spectral_slope(wavelengths, power, lo_km: float, hi_km: float) -> float:
|
|
64
|
+
"""Log-log slope over [lo_km, hi_km] (k^-3 synoptic, k^-5/3 mesoscale)."""
|
|
65
|
+
m = (wavelengths >= lo_km) & (wavelengths <= hi_km) & (power > 0)
|
|
66
|
+
return float(np.polyfit(np.log(1 / wavelengths[m]), np.log(power[m]), 1)[0])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def effective_resolution(
|
|
70
|
+
wavelengths, power, ref_slope: float = -5.0 / 3.0, fit_km=(300.0, 600.0), drop_db: float = 3.0
|
|
71
|
+
) -> float:
|
|
72
|
+
"""Effective resolution: wavelength where the spectrum falls `drop_db`
|
|
73
|
+
below the mesoscale power law extrapolated from `fit_km` (Skamarock 2004
|
|
74
|
+
in spirit). Returns km."""
|
|
75
|
+
m = (wavelengths >= fit_km[0]) & (wavelengths <= fit_km[1]) & (power > 0)
|
|
76
|
+
b = np.polyfit(np.log(1 / wavelengths[m]), np.log(power[m]), 1)
|
|
77
|
+
model = np.exp(np.polyval(b, np.log(1 / wavelengths)))
|
|
78
|
+
ratio = 10 * np.log10(np.maximum(power, 1e-300) / model)
|
|
79
|
+
below = np.where((wavelengths < fit_km[0]) & (ratio < -drop_db))[0]
|
|
80
|
+
return float(wavelengths[below[0]]) if len(below) else float("nan")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fidelity_resolution(wl, p, p_ref, frac=0.5):
|
|
84
|
+
"""Finest wavelength at which the spectrum still carries at least `frac`
|
|
85
|
+
of a reference spectrum's energy density — e.g. a forecast against its own
|
|
86
|
+
analysis. Unlike dissipation-range criteria (which can be undefined when a
|
|
87
|
+
spectrum has no clean roll-off), this is defined whenever the ratio drops
|
|
88
|
+
below `frac` anywhere, and it directly measures scale-dependent energy
|
|
89
|
+
loss rather than numerical damping. Returns NaN only if the spectrum never
|
|
90
|
+
falls below the threshold (fidelity maintained at all resolved scales)."""
|
|
91
|
+
import numpy as np
|
|
92
|
+
|
|
93
|
+
wl = np.asarray(wl, float)
|
|
94
|
+
r = np.asarray(p, float) / np.asarray(p_ref, float)
|
|
95
|
+
below = r < frac
|
|
96
|
+
if not below.any():
|
|
97
|
+
return float("nan")
|
|
98
|
+
i = np.argmax(below) # first scale (largest wl) below
|
|
99
|
+
if i == 0:
|
|
100
|
+
return float(wl[0])
|
|
101
|
+
# log-interpolate the crossing between i-1 and i
|
|
102
|
+
w1, w2 = np.log(wl[i - 1]), np.log(wl[i])
|
|
103
|
+
r1, r2 = r[i - 1], r[i]
|
|
104
|
+
t = (frac - r1) / (r2 - r1) if r2 != r1 else 0.0
|
|
105
|
+
return float(np.exp(w1 + t * (w2 - w1)))
|