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