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/degdays.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Degree days: the midpoint and integral conventions, degree hours by time of day, degree days from daily extremes and
|
|
2
|
+
a diurnal shape, expected degree days of a Gaussian temperature, weighted aggregation in the right order, local-day
|
|
3
|
+
assembly from UTC data, a 366-day calendar and a two-hinge balance-point fit.
|
|
4
|
+
|
|
5
|
+
Every degree-day function returns (hdd, cdd) in the units of its temperatures. The default base is 65 (degF, the
|
|
6
|
+
North American convention); pass base=18 (or 15.5, 18.3...) for degC.
|
|
7
|
+
|
|
8
|
+
Numerical notes that matter in practice:
|
|
9
|
+
- MIDPOINT AND INTEGRAL ARE DIFFERENT QUANTITIES. The midpoint degree day max(b - (hi + lo)/2, 0) is zero whenever
|
|
10
|
+
the mean of the extremes clears the base, yet a cool dawn still sits below it for hours; the integral (the time
|
|
11
|
+
mean of max(b - T(t), 0), i.e. degree hours / 24) sees that. They agree only when the whole day is on one side of
|
|
12
|
+
the base. A day that swings 58-78 around a 65 base (mean 68, amplitude 10) has a midpoint HDD of 0 and an integral
|
|
13
|
+
HDD of 1.83 -- shoulder seasons are where the conventions part. Totals over a winter barely move.
|
|
14
|
+
Neither is "right": the midpoint is the traded and published convention, the integral is a second, complementary
|
|
15
|
+
measure. Degree hours split into time-of-day blocks keep where in the day the heating or cooling falls.
|
|
16
|
+
- KINK FIRST, WEIGHT AFTER. A degree day is a convex (hinged) function of temperature, so by Jensen's inequality
|
|
17
|
+
max(b - sum w T, 0) <= sum w max(b - T, 0): weighting temperatures across locations (or ensemble members) first
|
|
18
|
+
and taking the degree day of the weighted mean UNDERSTATES both HDD and CDD, without exception, and the error is
|
|
19
|
+
largest exactly when it matters -- a warm aggregate annihilates the heating of every cold location in it (a
|
|
20
|
+
national mean above the base reads zero HDD while half the population heats). ``weighted_dd`` applies the kink per
|
|
21
|
+
location (per member) and weights afterwards. The same holds for any time aggregation.
|
|
22
|
+
- EXPECTED DEGREE DAYS ARE NOT THE DEGREE DAYS OF THE EXPECTED TEMPERATURE. For T ~ N(mu, sigma^2),
|
|
23
|
+
E[max(b - T, 0)] = (b - mu) Phi(z) + sigma phi(z), z = (b - mu)/sigma (Thom 1954), which exceeds max(b - mu, 0) by
|
|
24
|
+
up to sigma/sqrt(2 pi) (0.4 sigma) at mu = b. Use it to turn an ensemble or seasonal mean and spread into degree
|
|
25
|
+
days; HDD - CDD = b - mu exactly, as for the midpoint.
|
|
26
|
+
- LOCAL DAYS, NOT UTC DAYS. A calendar day is local, and a local day has 23 or 25 hours across a DST change.
|
|
27
|
+
``local_days`` assembles them from UTC timestamps with zoneinfo and, by default, drops days with fewer samples than
|
|
28
|
+
that day really has -- a partial first or last day otherwise shows up as a spuriously mild (or hot) day.
|
|
29
|
+
- A NORMALISED SHAPE IS NOT A TYPICAL DAY. dd_from_extremes builds T(h) = lo + (hi - lo) w(h). If w is the mean of
|
|
30
|
+
normalised daily curves it is flatter than any real day (days peak at different hours), which biases the integral
|
|
31
|
+
degree days; take w from a model of the typical day, and treat the reconstruction as an approximation (a day's
|
|
32
|
+
curve cannot be recovered from its two extremes).
|
|
33
|
+
- 366 SLOTS. A day-of-year climatology indexed 0..365 with 29 Feb in its own slot (59) keeps 1 March at 60 in every
|
|
34
|
+
year; indexing by tm_yday shifts every day after February by one in leap years.
|
|
35
|
+
- BALANCE POINTS ARE FITTED, NOT ASSUMED. Energy demand against temperature is a "smile": a heating hinge, a
|
|
36
|
+
dead band, a cooling hinge. fit_balance_points finds both hinges by least squares over a grid (the five-parameter
|
|
37
|
+
change-point model of Kissock, Haberl & Claridge 2003), optionally with extra regressors (day of week, trend).
|
|
38
|
+
|
|
39
|
+
References: Thom (1954, Mon. Wea. Rev. 82, 1-6) on degree days and the normal distribution; Kissock, Haberl &
|
|
40
|
+
Claridge (2003, ASHRAE Research Project 1050: Inverse Modeling Toolkit) on change-point models; PEP 615 (zoneinfo).
|
|
41
|
+
|
|
42
|
+
Requires: numpy, scipy (normal distribution); zoneinfo (standard library, Python >= 3.9) for local days.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import datetime as _dt
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
|
|
50
|
+
import numpy as np
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"midpoint_dd",
|
|
54
|
+
"integral_dd",
|
|
55
|
+
"degree_hours",
|
|
56
|
+
"dd_from_extremes",
|
|
57
|
+
"expected_dd",
|
|
58
|
+
"weighted_dd",
|
|
59
|
+
"local_days",
|
|
60
|
+
"LocalDays",
|
|
61
|
+
"day_length_hours",
|
|
62
|
+
"doy366",
|
|
63
|
+
"fit_balance_points",
|
|
64
|
+
"BalancePoints",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
BASE = 65.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _as(x):
|
|
71
|
+
return np.asarray(x, dtype=float)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def midpoint_dd(tmax, tmin, base: float = BASE):
|
|
75
|
+
"""Midpoint degree days from daily extremes: m = (tmax + tmin)/2, (max(base - m, 0), max(m - base, 0))."""
|
|
76
|
+
m = 0.5 * (_as(tmax) + _as(tmin))
|
|
77
|
+
return np.maximum(base - m, 0.0), np.maximum(m - base, 0.0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def integral_dd(temps, base: float = BASE, axis: int = -1, method: str = "mean"):
|
|
81
|
+
"""Integral degree days from sub-daily temperatures along `axis` (one day per slice).
|
|
82
|
+
|
|
83
|
+
method "mean" : the mean of max(base - T, 0) over the samples, each standing for an equal share of the day
|
|
84
|
+
(hourly data: degree hours / 24);
|
|
85
|
+
method "linear" : the exact time mean of the hinge of the piecewise-linear curve through the samples (trapezoid
|
|
86
|
+
with the base crossings solved inside each interval) -- better for 3- or 6-hourly samples.
|
|
87
|
+
Spans first to last sample; pass the next day's first sample appended to close the day.
|
|
88
|
+
NaN in a slice -> NaN (strict)."""
|
|
89
|
+
t = np.moveaxis(_as(temps), axis, -1)
|
|
90
|
+
if method == "mean":
|
|
91
|
+
return np.maximum(base - t, 0.0).mean(-1), np.maximum(t - base, 0.0).mean(-1)
|
|
92
|
+
if method != "linear":
|
|
93
|
+
raise ValueError("method is 'mean' or 'linear'")
|
|
94
|
+
if t.shape[-1] < 2:
|
|
95
|
+
raise ValueError("'linear' needs at least two samples")
|
|
96
|
+
a, b = t[..., :-1], t[..., 1:]
|
|
97
|
+
|
|
98
|
+
def seg(u, v): # mean over [0, 1] of max(u + (v - u) s, 0) for a linear segment u -> v
|
|
99
|
+
both = (u >= 0) & (v >= 0)
|
|
100
|
+
none = (u <= 0) & (v <= 0)
|
|
101
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
102
|
+
cross = np.where(u > 0, u * u, v * v) / (2.0 * np.abs(v - u))
|
|
103
|
+
return np.where(both, 0.5 * (u + v), np.where(none, 0.0, cross))
|
|
104
|
+
|
|
105
|
+
h = seg(base - a, base - b).mean(-1)
|
|
106
|
+
c = seg(a - base, b - base).mean(-1)
|
|
107
|
+
return h, c
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def degree_hours(
|
|
111
|
+
temps, hours, base: float = BASE, blocks=((0, 6), (6, 12), (12, 18), (18, 24)), step: float = 1.0, axis: int = -1
|
|
112
|
+
):
|
|
113
|
+
"""Heating and cooling degree hours by time-of-day block.
|
|
114
|
+
|
|
115
|
+
temps : (..., n) samples along `axis`; hours : (n,) local hour of each sample (0 <= h < 24)
|
|
116
|
+
blocks: [start, end) local-hour blocks; step: hours each sample represents (1 for hourly data).
|
|
117
|
+
Returns (hdh, cdh), each (..., len(blocks)). Over blocks that tile the day, sum(hdh)/24 = integral_dd."""
|
|
118
|
+
t = np.moveaxis(_as(temps), axis, -1)
|
|
119
|
+
hr = _as(hours)
|
|
120
|
+
if hr.shape != (t.shape[-1],):
|
|
121
|
+
raise ValueError("one local hour per sample")
|
|
122
|
+
hh = np.maximum(base - t, 0.0) * step
|
|
123
|
+
cc = np.maximum(t - base, 0.0) * step
|
|
124
|
+
H, C = [], []
|
|
125
|
+
for lo, hi in blocks:
|
|
126
|
+
m = (hr >= lo) & (hr < hi)
|
|
127
|
+
H.append(hh[..., m].sum(-1))
|
|
128
|
+
C.append(cc[..., m].sum(-1))
|
|
129
|
+
return np.stack(H, -1), np.stack(C, -1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def dd_from_extremes(tmax, tmin, shape, base: float = BASE):
|
|
133
|
+
"""Degree days from a day's extremes and a diurnal shape: T(h) = tmin + (tmax - tmin) w(h), with w = the shape
|
|
134
|
+
rescaled to [0, 1] along its last axis (so any curve -- hourly climatology, a model's typical day -- can be passed).
|
|
135
|
+
tmax/tmin broadcast against shape's leading axes. Returns the integral (hdd, cdd) of the reconstructed curve."""
|
|
136
|
+
w = _as(shape)
|
|
137
|
+
lo = w.min(-1, keepdims=True)
|
|
138
|
+
rng = w.max(-1, keepdims=True) - lo
|
|
139
|
+
if (rng <= 0).any():
|
|
140
|
+
raise ValueError("the shape must vary over the day")
|
|
141
|
+
w = (w - lo) / rng
|
|
142
|
+
tmax = _as(tmax)[..., None]
|
|
143
|
+
tmin = _as(tmin)[..., None]
|
|
144
|
+
return integral_dd(tmin + (tmax - tmin) * w, base)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def expected_dd(mu, sigma, base: float = BASE):
|
|
148
|
+
"""Expected degree days of a Gaussian temperature N(mu, sigma^2) (Thom 1954):
|
|
149
|
+
E[max(b - T, 0)] = (b - mu) Phi(z) + sigma phi(z), E[max(T - b, 0)] = (mu - b) Phi(-z) + sigma phi(z),
|
|
150
|
+
z = (b - mu)/sigma; sigma = 0 gives the degree days of mu. Returns (hdd, cdd)."""
|
|
151
|
+
from scipy.stats import norm
|
|
152
|
+
|
|
153
|
+
mu, sigma = np.broadcast_arrays(_as(mu), _as(sigma))
|
|
154
|
+
if (sigma < 0).any():
|
|
155
|
+
raise ValueError("sigma must be non-negative")
|
|
156
|
+
s = np.where(sigma > 0, sigma, 1.0)
|
|
157
|
+
z = (base - mu) / s
|
|
158
|
+
h = (base - mu) * norm.cdf(z) + s * norm.pdf(z)
|
|
159
|
+
c = (mu - base) * norm.cdf(-z) + s * norm.pdf(z)
|
|
160
|
+
return (np.where(sigma > 0, h, np.maximum(base - mu, 0.0)), np.where(sigma > 0, c, np.maximum(mu - base, 0.0)))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def weighted_dd(temps, weights, base: float = BASE, axis: int = -1, min_coverage: float = 1.0):
|
|
164
|
+
"""Weighted (e.g. population-weighted) degree days with the kink applied FIRST, per location, then weighted:
|
|
165
|
+
hdd = sum_i w_i max(base - T_i, 0) / sum_i w_i (and cdd likewise)
|
|
166
|
+
temps : (..., nloc) daily temperatures (or midpoints) along `axis`; weights : (nloc,), any non-negative scale.
|
|
167
|
+
NaN locations drop out with their weight renormalised only while the weight present is >= min_coverage of the
|
|
168
|
+
total (default 1.0: any missing location makes the aggregate NaN). Weighting temperatures first and kinking the
|
|
169
|
+
mean is biased low by Jensen's inequality (module docstring)."""
|
|
170
|
+
t = np.moveaxis(_as(temps), axis, -1)
|
|
171
|
+
w = _as(weights)
|
|
172
|
+
if w.shape != (t.shape[-1],) or (w < 0).any():
|
|
173
|
+
raise ValueError("weights: one non-negative weight per location")
|
|
174
|
+
ok = np.isfinite(t)
|
|
175
|
+
wv = np.where(ok, w, 0.0)
|
|
176
|
+
have = wv.sum(-1)
|
|
177
|
+
tot = w.sum()
|
|
178
|
+
out = []
|
|
179
|
+
for f in (np.maximum(base - t, 0.0), np.maximum(t - base, 0.0)):
|
|
180
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
181
|
+
v = (np.where(ok, f, 0.0) * wv).sum(-1) / have
|
|
182
|
+
out.append(np.where(have >= min_coverage * tot - 1e-12 * tot, v, np.nan))
|
|
183
|
+
return tuple(out)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --------------------------------------------------------------------------
|
|
187
|
+
# Local days
|
|
188
|
+
# --------------------------------------------------------------------------
|
|
189
|
+
def _zone(tz):
|
|
190
|
+
from zoneinfo import ZoneInfo
|
|
191
|
+
|
|
192
|
+
return ZoneInfo(tz) if isinstance(tz, str) else tz
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _utc_seconds(times):
|
|
196
|
+
t = np.asarray(times)
|
|
197
|
+
if np.issubdtype(t.dtype, np.datetime64):
|
|
198
|
+
return t.astype("datetime64[s]").astype(np.int64)
|
|
199
|
+
out = []
|
|
200
|
+
for x in t.ravel():
|
|
201
|
+
if isinstance(x, _dt.datetime):
|
|
202
|
+
naive = x.astimezone(_dt.timezone.utc).replace(tzinfo=None) if x.tzinfo is not None else x
|
|
203
|
+
out.append(int((np.datetime64(naive, "s") - np.datetime64(0, "s")) / np.timedelta64(1, "s")))
|
|
204
|
+
else:
|
|
205
|
+
out.append(int(np.datetime64(x, "s").astype(np.int64)))
|
|
206
|
+
return np.array(out, np.int64)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def day_length_hours(dates, tz) -> np.ndarray:
|
|
210
|
+
"""Length in hours of each local calendar day in time zone tz (IANA name or tzinfo): 24, or 23 / 25 across a DST
|
|
211
|
+
change (other values for historical offset changes)."""
|
|
212
|
+
z = _zone(tz)
|
|
213
|
+
out = []
|
|
214
|
+
for d in np.atleast_1d(np.asarray(dates, dtype="datetime64[D]")):
|
|
215
|
+
day = d.astype(_dt.date)
|
|
216
|
+
a = _dt.datetime(day.year, day.month, day.day, tzinfo=z)
|
|
217
|
+
nx = day + _dt.timedelta(days=1)
|
|
218
|
+
b = _dt.datetime(nx.year, nx.month, nx.day, tzinfo=z)
|
|
219
|
+
out.append((b.astimezone(_dt.timezone.utc) - a.astimezone(_dt.timezone.utc)).total_seconds() / 3600.0)
|
|
220
|
+
return np.array(out)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass
|
|
224
|
+
class LocalDays:
|
|
225
|
+
"""Local calendar days assembled from UTC samples: dates (datetime64[D]), index (one array of sample indices per
|
|
226
|
+
day), n (samples present), expected (the fewest samples a gap-free day of that length holds at the sampling step,
|
|
227
|
+
floor(hours / step)), hours (the day's length, 23/24/25) and local_hour (per sample, the local wall-clock hour,
|
|
228
|
+
fractional; it repeats or skips an hour on DST days)."""
|
|
229
|
+
|
|
230
|
+
dates: np.ndarray
|
|
231
|
+
index: list
|
|
232
|
+
n: np.ndarray
|
|
233
|
+
expected: np.ndarray
|
|
234
|
+
hours: np.ndarray
|
|
235
|
+
local_hour: np.ndarray
|
|
236
|
+
|
|
237
|
+
def reduce(self, values, func=np.mean, axis: int = -1):
|
|
238
|
+
"""Apply func (e.g. np.max, np.min, np.mean) per local day along `axis` (the sample axis): (..., nday)."""
|
|
239
|
+
v = np.moveaxis(_as(values), axis, -1)
|
|
240
|
+
if not self.index:
|
|
241
|
+
return np.empty((*v.shape[:-1], 0))
|
|
242
|
+
return np.stack([func(v[..., i], axis=-1) for i in self.index], -1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def local_days(times_utc, tz, step_hours=None, complete: bool = True) -> LocalDays:
|
|
246
|
+
"""Group UTC timestamps into local calendar days of time zone tz (IANA name, e.g. "America/Chicago"), DST-safe.
|
|
247
|
+
|
|
248
|
+
times_utc : datetime64 (taken as UTC), naive datetimes (UTC) or aware datetimes
|
|
249
|
+
step_hours : sampling interval; None = the median spacing
|
|
250
|
+
complete : keep only days with every sample the day really has (n >= floor(hours / step): 23, 24 or 25 for hourly
|
|
251
|
+
data), so a partial first or last day, or a gap, never yields a spuriously mild or hot day; False
|
|
252
|
+
keeps every day with any sample.
|
|
253
|
+
Use LocalDays.reduce to take the daily max, min or mean of a series sampled at these times."""
|
|
254
|
+
z = _zone(tz)
|
|
255
|
+
sec = _utc_seconds(times_utc)
|
|
256
|
+
if step_hours is None:
|
|
257
|
+
step_hours = float(np.median(np.diff(np.sort(sec)))) / 3600.0 if sec.size > 1 else 1.0
|
|
258
|
+
loc_date = np.empty(sec.size, "datetime64[D]")
|
|
259
|
+
loc_hour = np.empty(sec.size)
|
|
260
|
+
cache = {}
|
|
261
|
+
for i, s in enumerate(sec):
|
|
262
|
+
hour = int(s // 3600)
|
|
263
|
+
if hour not in cache:
|
|
264
|
+
u = _dt.datetime.fromtimestamp(hour * 3600, _dt.timezone.utc).astimezone(z)
|
|
265
|
+
cache[hour] = (np.datetime64(u.date(), "D"), u.hour + u.minute / 60.0)
|
|
266
|
+
d, h0 = cache[hour]
|
|
267
|
+
loc_date[i] = d
|
|
268
|
+
loc_hour[i] = h0 + (s - hour * 3600) / 3600.0
|
|
269
|
+
days, inv = np.unique(loc_date, return_inverse=True)
|
|
270
|
+
hours = day_length_hours(days, z) if days.size else np.zeros(0)
|
|
271
|
+
expected = np.floor(hours / step_hours + 1e-9).astype(int)
|
|
272
|
+
idx = [np.flatnonzero(inv == k) for k in range(days.size)]
|
|
273
|
+
n = np.array([i.size for i in idx], int)
|
|
274
|
+
keep = n >= expected if complete else n > 0
|
|
275
|
+
return LocalDays(days[keep], [i for i, k in zip(idx, keep) if k], n[keep], expected[keep], hours[keep], loc_hour)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def doy366(dates) -> np.ndarray:
|
|
279
|
+
"""Day-of-year index 0..365 on a 366-slot calendar: 29 Feb is slot 59 and 1 March is 60 in EVERY year, so a
|
|
280
|
+
climatology lines up across leap and common years (31 Dec is 365 always). Slot 59 holds only the leap years: fill
|
|
281
|
+
it from them or from its neighbours, and smooth cyclically over the 366 slots (harmonics in 2 pi slot / 366)."""
|
|
282
|
+
d = np.asarray(dates, dtype="datetime64[D]")
|
|
283
|
+
y = d.astype("datetime64[Y]")
|
|
284
|
+
doy = (d - y.astype("datetime64[D]")).astype(int) # 0-based
|
|
285
|
+
yr = y.astype(int) + 1970
|
|
286
|
+
leap = (yr % 4 == 0) & ((yr % 100 != 0) | (yr % 400 == 0))
|
|
287
|
+
return np.where(~leap & (doy >= 59), doy + 1, doy)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# --------------------------------------------------------------------------
|
|
291
|
+
# Balance points
|
|
292
|
+
# --------------------------------------------------------------------------
|
|
293
|
+
@dataclass
|
|
294
|
+
class BalancePoints:
|
|
295
|
+
"""heat_base, cool_base (the hinges), intercept (load in the dead band, at zero extra regressors), heat_slope
|
|
296
|
+
(load per degree below heat_base, >0 when heating adds load), cool_slope (per degree above cool_base), middle_slope
|
|
297
|
+
(0 unless linear=True), extra (coefficients of X), sse, r2, n."""
|
|
298
|
+
|
|
299
|
+
heat_base: float
|
|
300
|
+
cool_base: float
|
|
301
|
+
intercept: float
|
|
302
|
+
heat_slope: float
|
|
303
|
+
cool_slope: float
|
|
304
|
+
middle_slope: float
|
|
305
|
+
extra: np.ndarray
|
|
306
|
+
sse: float
|
|
307
|
+
r2: float
|
|
308
|
+
n: int
|
|
309
|
+
|
|
310
|
+
def predict(self, temp, X=None):
|
|
311
|
+
t = _as(temp)
|
|
312
|
+
y = (
|
|
313
|
+
self.intercept
|
|
314
|
+
+ self.heat_slope * np.maximum(self.heat_base - t, 0.0)
|
|
315
|
+
+ self.cool_slope * np.maximum(t - self.cool_base, 0.0)
|
|
316
|
+
+ self.middle_slope * t
|
|
317
|
+
)
|
|
318
|
+
if X is not None and self.extra.size:
|
|
319
|
+
y = y + np.asarray(X, float) @ self.extra
|
|
320
|
+
return y
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def fit_balance_points(
|
|
324
|
+
temp, load, X=None, grid=None, step: float = 1.0, linear: bool = False, min_side: int = 10
|
|
325
|
+
) -> BalancePoints:
|
|
326
|
+
"""Two-hinge (five-parameter change-point) fit of load against temperature,
|
|
327
|
+
load = b0 + a_h max(Th - T, 0) + a_c max(T - Tc, 0) [+ b1 T] [+ X beta],
|
|
328
|
+
with (Th <= Tc) chosen by least squares over a grid of hinge pairs, every pair solved exactly by OLS.
|
|
329
|
+
|
|
330
|
+
temp, load : (n,), non-finite pairs dropped; X : optional (n, k) extra regressors (day-of-week dummies, a trend)
|
|
331
|
+
grid : candidate hinge temperatures; None = from the 5th to the 95th percentile of temp in `step` units
|
|
332
|
+
linear : add a slope in the dead band (b1 T)
|
|
333
|
+
min_side : at least this many points must lie beyond each hinge."""
|
|
334
|
+
t = _as(temp).ravel()
|
|
335
|
+
y = _as(load).ravel()
|
|
336
|
+
Xe = np.zeros((t.size, 0)) if X is None else np.asarray(X, float).reshape(t.size, -1)
|
|
337
|
+
ok = np.isfinite(t) & np.isfinite(y) & np.isfinite(Xe).all(1)
|
|
338
|
+
t, y, Xe = t[ok], y[ok], Xe[ok]
|
|
339
|
+
n = t.size
|
|
340
|
+
if grid is None:
|
|
341
|
+
lo, hi = np.percentile(t, [5, 95])
|
|
342
|
+
grid = np.arange(np.floor(lo / step) * step, np.ceil(hi / step) * step + step / 2, step)
|
|
343
|
+
grid = np.unique(_as(grid))
|
|
344
|
+
B = np.column_stack([np.ones(n)] + ([t] if linear else []) + [Xe])
|
|
345
|
+
kb = B.shape[1]
|
|
346
|
+
Hm = np.maximum(grid[None, :] - t[:, None], 0.0) # heating hinge column for every candidate
|
|
347
|
+
Cm = np.maximum(t[:, None] - grid[None, :], 0.0) # cooling hinge column
|
|
348
|
+
i, j = np.nonzero(np.triu(np.ones((grid.size, grid.size), bool), 1 if linear else 0))
|
|
349
|
+
keep = ((Hm > 0).sum(0)[i] >= min_side) & ((Cm > 0).sum(0)[j] >= min_side)
|
|
350
|
+
i, j = i[keep], j[keep]
|
|
351
|
+
if not i.size:
|
|
352
|
+
raise ValueError("no hinge pair leaves min_side points beyond both hinges")
|
|
353
|
+
# every pair solved through its normal equations (batched); the winner is refitted by lstsq below
|
|
354
|
+
BtH, BtC = B.T @ Hm, B.T @ Cm
|
|
355
|
+
M = np.empty((i.size, kb + 2, kb + 2))
|
|
356
|
+
M[:, :kb, :kb] = B.T @ B
|
|
357
|
+
M[:, :kb, kb] = M[:, kb, :kb] = BtH[:, i].T
|
|
358
|
+
M[:, :kb, kb + 1] = M[:, kb + 1, :kb] = BtC[:, j].T
|
|
359
|
+
M[:, kb, kb] = (Hm * Hm).sum(0)[i]
|
|
360
|
+
M[:, kb + 1, kb + 1] = (Cm * Cm).sum(0)[j]
|
|
361
|
+
M[:, kb, kb + 1] = M[:, kb + 1, kb] = (Hm.T @ Cm)[i, j]
|
|
362
|
+
yc = y - y.mean() # the intercept absorbs it; better conditioned
|
|
363
|
+
v = np.concatenate([np.broadcast_to(B.T @ yc, (i.size, kb)), (Hm.T @ yc)[i, None], (Cm.T @ yc)[j, None]], 1)
|
|
364
|
+
try:
|
|
365
|
+
beta = np.linalg.solve(M, v[..., None])[..., 0]
|
|
366
|
+
except np.linalg.LinAlgError: # collinear extra regressors
|
|
367
|
+
beta = (np.linalg.pinv(M) @ v[..., None])[..., 0]
|
|
368
|
+
k = int(np.argmin(yc @ yc - np.sum(beta * v, 1)))
|
|
369
|
+
th, tc = grid[i[k]], grid[j[k]]
|
|
370
|
+
A = np.column_stack([B[:, : 1 + linear], Hm[:, i[k]], Cm[:, j[k]], Xe])
|
|
371
|
+
coef = np.linalg.lstsq(A, y, rcond=None)[0]
|
|
372
|
+
sse = float(np.sum((y - A @ coef) ** 2))
|
|
373
|
+
sst = float(yc @ yc)
|
|
374
|
+
j = 2 if linear else 1
|
|
375
|
+
return BalancePoints(
|
|
376
|
+
float(th),
|
|
377
|
+
float(tc),
|
|
378
|
+
float(coef[0]),
|
|
379
|
+
float(coef[j]),
|
|
380
|
+
float(coef[j + 1]),
|
|
381
|
+
float(coef[1]) if linear else 0.0,
|
|
382
|
+
coef[j + 2 :].copy(),
|
|
383
|
+
sse,
|
|
384
|
+
float(1 - sse / sst) if sst > 0 else float("nan"),
|
|
385
|
+
int(n),
|
|
386
|
+
)
|
quantmet/ensemble.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Ensemble diagnostics: Benjamini-Hochberg FDR masking, ensemble sensitivity, member-group composites, and a
|
|
2
|
+
trailing-pair estimator of systematic cycle offsets (the "windshield wiper").
|
|
3
|
+
|
|
4
|
+
Ensemble sensitivity (Torn & Hakim 2008; Ancell & Hakim 2007): across ensemble members, regress a later field J
|
|
5
|
+
(e.g. day-7 z500) on an earlier scalar x (e.g. a tropical cyclone's day-3 latitude):
|
|
6
|
+
dJ/dx = cov(J, x) / var(x), r = corr(J, x), t = r sqrt((n-2)/(1-r^2)), two-sided p from Student t.
|
|
7
|
+
It is an ASSOCIATION across members, not a causal response.
|
|
8
|
+
|
|
9
|
+
Field significance: thousands of grid points tested at 5 % give hundreds of false positives. ``bh`` controls the
|
|
10
|
+
false-discovery rate (Benjamini & Hochberg 1995): the expected share of false positives among the shaded points is
|
|
11
|
+
<= q. Apply it over everything shown together (all panels of a figure), not panel by panel.
|
|
12
|
+
|
|
13
|
+
Cycle offsets: some models' runs from one initialisation hour sit systematically off the runs from another at the
|
|
14
|
+
SAME valid time (seen in AIFS-ENS 00Z vs 12Z tropical-wind indices, growing roughly linearly with lead). Animating
|
|
15
|
+
alternating cycles then rocks back and forth. ``cycle_offset`` estimates the offset as a function of lead from the
|
|
16
|
+
trailing same-valid-time differences between the two families (no lookahead: only pairs initialised before the target
|
|
17
|
+
run), to be SUBTRACTED from the target run. Archive RAW values; archiving corrected ones collapses future estimates.
|
|
18
|
+
|
|
19
|
+
Requires: numpy, scipy (quantmet.stats for the FDR).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
__all__ = ["bh", "sensitivity", "group_composite", "cycle_offset"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def bh(p: np.ndarray, q: float = 0.10) -> np.ndarray:
|
|
30
|
+
"""Benjamini-Hochberg discoveries at FDR q: boolean mask, same shape as p; NaN p-values are never significant.
|
|
31
|
+
Thin alias of quantmet.stats.benjamini_hochberg."""
|
|
32
|
+
from .stats import benjamini_hochberg
|
|
33
|
+
|
|
34
|
+
return benjamini_hochberg(p, q)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sensitivity(x: np.ndarray, J: np.ndarray, q: float = 0.10, min_sd: float = 0.0) -> dict | None:
|
|
38
|
+
"""Regression of J (n, ...) on the scalar predictor x (n,) across n members.
|
|
39
|
+
|
|
40
|
+
Returns {slope (J units per x unit), r, p, sig (BH at q), n} or None if fewer than 4 members or x's spread is
|
|
41
|
+
below min_sd (members agree on x: nothing to regress on)."""
|
|
42
|
+
from scipy import stats as sst
|
|
43
|
+
|
|
44
|
+
x = np.asarray(x, float)
|
|
45
|
+
J = np.asarray(J, float)
|
|
46
|
+
n = len(x)
|
|
47
|
+
if n < 4 or x.std() <= min_sd:
|
|
48
|
+
return None
|
|
49
|
+
xa = x - x.mean()
|
|
50
|
+
Ja = J - J.mean(0)
|
|
51
|
+
sxy = np.tensordot(xa, Ja, axes=(0, 0))
|
|
52
|
+
slope = sxy / np.sum(xa**2)
|
|
53
|
+
r = sxy / (np.sqrt(np.sum(xa**2)) * np.sqrt(np.sum(Ja**2, 0)) + 1e-30)
|
|
54
|
+
t = r * np.sqrt((n - 2) / np.clip(1 - r**2, 1e-12, None))
|
|
55
|
+
p = 2 * sst.t.sf(np.abs(t), n - 2)
|
|
56
|
+
return dict(slope=slope, r=r, p=p, sig=bh(p, q), n=n)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def group_composite(A: np.ndarray, B: np.ndarray, q: float = 0.10, min_group: int = 8) -> dict | None:
|
|
60
|
+
"""Mean(A) - mean(B) of two member groups (n_a, ...), (n_b, ...) with a Welch t-test per point and BH masking;
|
|
61
|
+
None if either group has fewer than min_group members."""
|
|
62
|
+
from scipy import stats as sst
|
|
63
|
+
|
|
64
|
+
A = np.asarray(A, float)
|
|
65
|
+
B = np.asarray(B, float)
|
|
66
|
+
if len(A) < min_group or len(B) < min_group:
|
|
67
|
+
return None
|
|
68
|
+
_t, p = sst.ttest_ind(A, B, axis=0, equal_var=False)
|
|
69
|
+
return dict(diff=A.mean(0) - B.mean(0), p=p, sig=bh(p, q), n=(len(A), len(B)))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cycle_offset(
|
|
73
|
+
records, target_init, leads, ref_hour: int = 0, alt_hour: int = 12, n_pairs: int = 7, min_pairs: int = 3
|
|
74
|
+
):
|
|
75
|
+
"""Offset of the ``alt_hour`` family relative to the ``ref_hour`` family, as a function of lead.
|
|
76
|
+
|
|
77
|
+
records : iterable of (init, leads_days, values) with init a numpy/pandas datetime, leads_days (L,), values (L, k)
|
|
78
|
+
-- RAW ensemble means of k quantities.
|
|
79
|
+
target_init : the init of the run to correct; leads : its leads (days).
|
|
80
|
+
Pairs are (alt run, the ref run initialised (alt_hour - ref_hour) h earlier); only pairs with alt init strictly
|
|
81
|
+
before target_init are used (no lookahead), newest n_pairs first. Differences alt - ref at common VALID times are
|
|
82
|
+
averaged per alt-run lead and interpolated to ``leads``. Returns (L_target, k) to subtract, zeros when the
|
|
83
|
+
target is a ref run or fewer than min_pairs pairs exist."""
|
|
84
|
+
leads = np.asarray(leads, float)
|
|
85
|
+
recs = [(np.datetime64(i, "s"), np.asarray(l, float), np.asarray(v, float)) for i, l, v in records]
|
|
86
|
+
k = recs[0][2].shape[1] if recs else 1
|
|
87
|
+
zero = np.zeros((len(leads), k))
|
|
88
|
+
tgt = np.datetime64(target_init, "s")
|
|
89
|
+
|
|
90
|
+
def hour(t):
|
|
91
|
+
return int((t - t.astype("datetime64[D]")) / np.timedelta64(1, "h"))
|
|
92
|
+
|
|
93
|
+
if hour(tgt) != alt_hour:
|
|
94
|
+
return zero
|
|
95
|
+
by_init = {r[0]: r for r in recs}
|
|
96
|
+
gap = np.timedelta64(alt_hour - ref_hour, "h")
|
|
97
|
+
diffs: dict[float, list] = {}
|
|
98
|
+
used = 0
|
|
99
|
+
for init, lb, vb in sorted(recs, key=lambda r: r[0], reverse=True):
|
|
100
|
+
if used >= n_pairs:
|
|
101
|
+
break
|
|
102
|
+
if hour(init) != alt_hour or init >= tgt:
|
|
103
|
+
continue
|
|
104
|
+
mate = by_init.get(init - gap)
|
|
105
|
+
if mate is None:
|
|
106
|
+
continue
|
|
107
|
+
_, la, va = mate
|
|
108
|
+
va_by_valid = {mate[0] + np.timedelta64(int(np.rint(x * 86400)), "s"): j for j, x in enumerate(la)}
|
|
109
|
+
hit = False
|
|
110
|
+
for i, ld in enumerate(lb):
|
|
111
|
+
j = va_by_valid.get(init + np.timedelta64(int(np.rint(ld * 86400)), "s"))
|
|
112
|
+
if j is not None:
|
|
113
|
+
diffs.setdefault(round(float(ld), 3), []).append(vb[i] - va[j])
|
|
114
|
+
hit = True
|
|
115
|
+
used += hit
|
|
116
|
+
if used < min_pairs:
|
|
117
|
+
return zero
|
|
118
|
+
grid = np.array(sorted(diffs))
|
|
119
|
+
mean = np.array([np.mean(diffs[g], axis=0) for g in grid])
|
|
120
|
+
return np.stack([np.interp(leads, grid, mean[:, c]) for c in range(k)], axis=1)
|