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 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)
@@ -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
+ ]
@@ -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)))