forecastscope 0.1.0__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.
@@ -0,0 +1,267 @@
1
+ """Nonlinear dynamics and chaos: phase-space embedding, correlation dimension, Lyapunov, RQA, 0-1, surrogates.
2
+
3
+ Is the series the output of a low-dimensional deterministic (possibly chaotic) system, or is it stochastic?
4
+ This distinction bounds forecastability: a chaotic system is deterministic yet has a finite prediction
5
+ horizon (errors grow like e^{lambda t}), while a stochastic one has no such structure to exploit. The tools
6
+ here reconstruct the state space (Takens), measure its dimension (Grassberger-Procaccia) and its sensitivity
7
+ to initial conditions (largest Lyapunov exponent), visualize recurrences (RQA), and - crucially - guard every
8
+ "it's chaotic" claim behind SURROGATE testing, because colored noise can mimic a low correlation dimension.
9
+
10
+ Methods / references (verified 2026-07-04; dossier research-nonlinear-dynamics-2026-07-04.md):
11
+ * Takens time-delay embedding - Takens 1981, LNM 898:366-381, DOI 10.1007/BFb0091924.
12
+ * Correlation dimension D2 (Grassberger-Procaccia) - 1983, Physica D 9:189-208,
13
+ DOI 10.1016/0167-2789(83)90298-1. ``nolds.corr_dim``.
14
+ * Largest Lyapunov exponent - Rosenstein et al. 1993, Physica D 65:117-134,
15
+ DOI 10.1016/0167-2789(93)90009-P. ``nolds.lyap_r``.
16
+ * Recurrence plots + RQA - Eckmann, Kamphorst & Ruelle 1987, EPL 4(9):973-977,
17
+ DOI 10.1209/0295-5075/4/9/004; Marwan et al. 2007, Phys. Rep. 438:237-329,
18
+ DOI 10.1016/j.physrep.2006.11.001. RQA implemented directly (RR/DET/LAM/L_max).
19
+ * IAAFT surrogates - Schreiber & Schmitz 1996, PRL 77:635-638, DOI 10.1103/PhysRevLett.77.635; the
20
+ surrogate-data method: Theiler et al. 1992, Physica D 58:77-94, DOI 10.1016/0167-2789(92)90102-S.
21
+
22
+ HONESTY GATE: Osborne & Provenzale 1989 showed colored noise yields a finite, saturating correlation
23
+ dimension - so a low D2 alone does NOT prove chaos. The unit requires the surrogate test (a nonlinear
24
+ statistic must differ from phase-randomized surrogates) before any determinism/chaos claim.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import warnings
29
+ from dataclasses import dataclass
30
+
31
+ import numpy as np
32
+
33
+
34
+ def _clean(x) -> np.ndarray:
35
+ a = np.asarray(x, dtype=float).ravel()
36
+ return a[np.isfinite(a)]
37
+
38
+
39
+ def time_delay_embed(x, dim: int = 3, lag: int = 1) -> np.ndarray:
40
+ """Takens embedding: rows are state vectors [x_t, x_{t+lag}, ..., x_{t+(dim-1)lag}]."""
41
+ a = _clean(x)
42
+ n = len(a) - (dim - 1) * lag
43
+ if n <= 0:
44
+ raise ValueError("series too short for this embedding dim/lag")
45
+ return np.column_stack([a[i * lag: i * lag + n] for i in range(dim)])
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ChaosMeasures:
50
+ """Correlation dimension + largest Lyapunov exponent, with a (surrogate-gated) chaos verdict."""
51
+
52
+ corr_dim: float
53
+ lyap_r: float
54
+ emb_dim: int
55
+ lag: int
56
+ likely_chaotic: bool # lyap_r > 0 AND the surrogate test rejects linearity (set by caller)
57
+ reference: str
58
+
59
+
60
+ def _seeded_slope(debug, seed: int) -> float:
61
+ """Fit nolds' exposed log-data with its robust model and an isolated seed."""
62
+ from sklearn.linear_model import LinearRegression, RANSACRegressor
63
+ from sklearn.preprocessing import PolynomialFeatures
64
+
65
+ x, y, _ = debug
66
+ x, y = np.asarray(x), np.asarray(y)
67
+ keep = np.isfinite(x) & np.isfinite(y)
68
+ x, y = x[keep], y[keep]
69
+ if len(x) < 2:
70
+ return float("nan")
71
+ model = RANSACRegressor(LinearRegression(fit_intercept=False), random_state=seed)
72
+ try:
73
+ model.fit(PolynomialFeatures(1).fit_transform(x.reshape(-1, 1)), y)
74
+ return float(model.estimator_.coef_[-1])
75
+ except ValueError:
76
+ return float(np.polyfit(x, y, 1)[0])
77
+
78
+
79
+ def chaos_measures(x, emb_dim: int = 4, lag: int = 1, seed: int = 0) -> ChaosMeasures:
80
+ """Grassberger-Procaccia correlation dimension + Rosenstein largest Lyapunov exponent (nolds)."""
81
+ import nolds
82
+
83
+ a = _clean(x)
84
+ if not isinstance(seed, int) or not 0 <= seed < 2**32:
85
+ raise ValueError("seed must be a nonnegative 32-bit integer")
86
+ _, d2_debug = nolds.corr_dim(a, emb_dim=emb_dim, lag=lag, fit="poly", debug_data=True)
87
+ d2 = _seeded_slope(d2_debug, seed)
88
+ # Rosenstein largest Lyapunov: positive => sensitive dependence (a chaos signature, not proof)
89
+ try:
90
+ _, lyap_debug = nolds.lyap_r(a, emb_dim=emb_dim, lag=lag, min_tsep=max(5, len(a) // 200),
91
+ fit="poly", debug_data=True)
92
+ lyap = _seeded_slope(lyap_debug, (seed + 1) % 2**32)
93
+ except Exception: # noqa: BLE001
94
+ lyap = float("nan")
95
+ return ChaosMeasures(
96
+ corr_dim=d2, lyap_r=lyap, emb_dim=emb_dim, lag=lag,
97
+ likely_chaotic=False, # only the surrogate-gated report sets this True
98
+ reference="Grassberger & Procaccia 1983, DOI 10.1016/0167-2789(83)90298-1; Rosenstein et al. 1993, DOI 10.1016/0167-2789(93)90009-P",
99
+ )
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class RQA:
104
+ """Recurrence quantification: recurrence rate, determinism, laminarity, longest diagonal line."""
105
+
106
+ recurrence_rate: float # RR: density of recurrence points
107
+ determinism: float # DET: fraction of recurrence points on diagonal lines (predictability)
108
+ laminarity: float # LAM: fraction on vertical lines (intermittency / laminar states)
109
+ l_max: int # longest diagonal line (inversely related to the largest Lyapunov)
110
+ threshold: float
111
+ reference: str
112
+
113
+
114
+ def recurrence_quantification(x, emb_dim: int = 3, lag: int = 1, rr_target: float = 0.1,
115
+ l_min: int = 2, max_n: int = 800) -> RQA:
116
+ """Build the recurrence matrix at a threshold giving ~rr_target density, then quantify it (RQA).
117
+
118
+ Following Marwan et al. 2007: recurrence R(i,j) = 1 iff ||v_i - v_j|| < eps, with eps chosen to hit a
119
+ target recurrence rate. DET = fraction of recurrence points forming diagonal lines of length >= l_min
120
+ (determinism); LAM = fraction forming vertical lines (laminarity); L_max = longest diagonal.
121
+ """
122
+ v = time_delay_embed(x, dim=emb_dim, lag=lag)
123
+ n = v.shape[0]
124
+ if n > max_n: # truncate (not subsample) to keep diagonals intact:
125
+ v = v[:max_n] # decimating would break the temporal continuity RQA needs
126
+ n = v.shape[0]
127
+ # pairwise Euclidean distances
128
+ diff = v[:, None, :] - v[None, :, :]
129
+ dist = np.sqrt((diff ** 2).sum(axis=2))
130
+ eps = float(np.quantile(dist, rr_target)) # threshold hitting ~rr_target density
131
+ rec = (dist < eps).astype(np.int8)
132
+ np.fill_diagonal(rec, 0) # exclude the trivial main diagonal (LOI)
133
+ rr = float(rec.sum() / (n * (n - 1)))
134
+ # diagonal-line lengths (determinism) and vertical-line lengths (laminarity)
135
+ diag_lengths = _line_lengths_diagonal(rec)
136
+ vert_lengths = _line_lengths_vertical(rec)
137
+ total_points = int(rec.sum())
138
+ det = float(sum(l for l in diag_lengths if l >= l_min) / total_points) if total_points else 0.0
139
+ lam = float(sum(l for l in vert_lengths if l >= l_min) / total_points) if total_points else 0.0
140
+ l_max = int(max(diag_lengths)) if diag_lengths else 0
141
+ return RQA(
142
+ recurrence_rate=rr, determinism=det, laminarity=lam, l_max=l_max, threshold=eps,
143
+ reference="Eckmann, Kamphorst & Ruelle 1987, DOI 10.1209/0295-5075/4/9/004; Marwan et al. 2007, DOI 10.1016/j.physrep.2006.11.001",
144
+ )
145
+
146
+
147
+ def _line_lengths_diagonal(rec: np.ndarray) -> list[int]:
148
+ """Lengths of consecutive-1 runs along all off-main diagonals (both triangles) of the recurrence matrix.
149
+
150
+ The matrix is symmetric, so we count both the positive and negative offsets: this matches the denominator
151
+ (the full recurrence-point count ``rec.sum()``) so DET is the true fraction of points on diagonal lines.
152
+ """
153
+ n = rec.shape[0]
154
+ lengths: list[int] = []
155
+ for k in range(1, n):
156
+ lengths.extend(_runs(np.diagonal(rec, offset=k))) # upper triangle
157
+ lengths.extend(_runs(np.diagonal(rec, offset=-k))) # lower triangle
158
+ return lengths
159
+
160
+
161
+ def _line_lengths_vertical(rec: np.ndarray) -> list[int]:
162
+ """Lengths of consecutive-1 runs down each column (vertical lines)."""
163
+ lengths: list[int] = []
164
+ for col in rec.T:
165
+ lengths.extend(_runs(col))
166
+ return lengths
167
+
168
+
169
+ def _runs(v: np.ndarray) -> list[int]:
170
+ """Lengths of maximal runs of 1s in a 0/1 vector."""
171
+ out: list[int] = []
172
+ run = 0
173
+ for val in v:
174
+ if val:
175
+ run += 1
176
+ elif run:
177
+ out.append(run)
178
+ run = 0
179
+ if run:
180
+ out.append(run)
181
+ return out
182
+
183
+
184
+ def iaaft_surrogate(x, n_iter: int = 100, seed: int = 0) -> np.ndarray:
185
+ """One IAAFT surrogate: same amplitude distribution AND power spectrum, but linearized dynamics.
186
+
187
+ Schreiber & Schmitz 1996. If a nonlinear statistic on the data differs from an ensemble of these
188
+ surrogates, the difference is genuine nonlinear structure, not an artifact of the spectrum/distribution.
189
+ """
190
+ a = _clean(x)
191
+ rng = np.random.default_rng(seed)
192
+ sorted_a = np.sort(a)
193
+ amp = np.abs(np.fft.rfft(a))
194
+ s = rng.permutation(a)
195
+ for _ in range(n_iter):
196
+ # match the power spectrum
197
+ S = np.fft.rfft(s)
198
+ S = amp * np.exp(1j * np.angle(S))
199
+ s = np.fft.irfft(S, n=len(a))
200
+ # match the amplitude distribution (rank-order remap)
201
+ ranks = np.argsort(np.argsort(s))
202
+ s = sorted_a[ranks]
203
+ return s
204
+
205
+
206
+ def zero_one_test(x, n_c: int = 100, seed: int = 0) -> float:
207
+ """Gottwald-Melbourne 0-1 test for chaos: K near 1 => chaotic, near 0 => regular (Gottwald & Melbourne 2004).
208
+
209
+ For random phases c, drive p_c(n) = sum x_j cos(j c), q_c(n) = sum x_j sin(j c); the mean-square
210
+ displacement M_c(n) grows linearly (K~1) for chaos and stays bounded (K~0) for regular dynamics. K is the
211
+ median asymptotic growth rate over the random phases.
212
+ """
213
+ a = _clean(x)
214
+ n = len(a)
215
+ rng = np.random.default_rng(seed)
216
+ a = a - a.mean()
217
+ ncut = n // 10
218
+ ks = []
219
+ for _ in range(n_c):
220
+ c = rng.uniform(np.pi / 5, 4 * np.pi / 5)
221
+ j = np.arange(1, n + 1)
222
+ p = np.cumsum(a * np.cos(j * c))
223
+ q = np.cumsum(a * np.sin(j * c))
224
+ M = np.array([np.mean((p[:n - k] - p[k:]) ** 2 + (q[:n - k] - q[k:]) ** 2) for k in range(1, ncut)])
225
+ t = np.arange(1, ncut)
226
+ # correlation of M with linear growth -> K
227
+ if np.std(M) > 0:
228
+ ks.append(float(np.corrcoef(t, M)[0, 1]))
229
+ return float(np.median(ks)) if ks else float("nan")
230
+
231
+
232
+ def nonlinear_report(x, emb_dim: int = 4, lag: int = 1, n_surrogates: int = 19, seed: int = 0) -> dict:
233
+ """Full nonlinear-dynamics panel WITH the surrogate gate: chaos claims require surrogate rejection."""
234
+ a = _clean(x)
235
+ cm = chaos_measures(a, emb_dim=emb_dim, lag=lag, seed=seed)
236
+ rqa = recurrence_quantification(a, emb_dim=emb_dim, lag=lag)
237
+ k01 = zero_one_test(a, seed=seed)
238
+ # surrogate test: compare the data's correlation dimension to an ensemble of IAAFT surrogates.
239
+ surr_d2 = []
240
+ for i in range(n_surrogates):
241
+ try:
242
+ surrogate_seed = (seed + i + 2) % 2**32
243
+ s = iaaft_surrogate(a, seed=surrogate_seed)
244
+ surr_d2.append(chaos_measures(s, emb_dim=emb_dim, lag=lag, seed=surrogate_seed).corr_dim)
245
+ except Exception: # noqa: BLE001
246
+ warnings.warn("Surrogate diagnostic unavailable", RuntimeWarning, stacklevel=2)
247
+ nonlinear_detected = None
248
+ if surr_d2:
249
+ mu, sd = float(np.mean(surr_d2)), float(np.std(surr_d2))
250
+ # data D2 significantly LOWER than surrogates => genuine (nonlinear) low-dim structure
251
+ nonlinear_detected = bool(sd > 0 and cm.corr_dim < mu - 2 * sd)
252
+ likely_chaotic = bool((cm.lyap_r > 0.01) and (k01 > 0.5) and (nonlinear_detected is True))
253
+ return {
254
+ "n": len(a),
255
+ "seed": seed,
256
+ "fit": "RANSAC with explicit random_state; OLS on no consensus",
257
+ "correlation_dimension": cm.corr_dim,
258
+ "largest_lyapunov": cm.lyap_r,
259
+ "zero_one_K": k01,
260
+ "rqa": {"recurrence_rate": rqa.recurrence_rate, "determinism": rqa.determinism,
261
+ "laminarity": rqa.laminarity, "l_max": rqa.l_max, "reference": rqa.reference},
262
+ "surrogate_test": {"n_surrogates": len(surr_d2), "nonlinear_detected": nonlinear_detected,
263
+ "reference": "Schreiber & Schmitz 1996, DOI 10.1103/PhysRevLett.77.635 (IAAFT); Theiler et al. 1992, DOI 10.1016/0167-2789(92)90102-S"},
264
+ "likely_chaotic": likely_chaotic,
265
+ "verdict_note": "chaos claimed only when Lyapunov>0 AND 0-1 K>0.5 AND surrogate rejects linearity",
266
+ "reference": cm.reference,
267
+ }
@@ -0,0 +1,218 @@
1
+ """Seasonality analysis: periodogram/Welch dominant period, seasonal strength, MSTL, seasonal subseries.
2
+
3
+ Seasonality is a pattern repeating at a fixed period m (a day, a week, a year). This module answers the four
4
+ questions a forecaster asks before choosing a seasonal model: (1) is there a dominant period at all, and what
5
+ is it? (2) how strong is the seasonality (and is it worth modelling)? (3) are there MULTIPLE seasonal periods
6
+ (e.g. daily + weekly)? (4) what does the per-phase seasonal profile look like? Each answer is delegated to
7
+ its authoritative implementation and wrapped in one stable, NaN-safe API that carries the primary reference.
8
+
9
+ Methods / references (verified 2026-07-04):
10
+ * Periodogram (Schuster 1898) / Welch (1967) - the squared magnitude of the FFT; the tallest non-DC peak
11
+ gives the dominant period. ``scipy.signal.periodogram`` / ``scipy.signal.welch``.
12
+ * Seasonal strength Fs = max(0, 1 - Var(R)/Var(S+R)) from an STL decomposition - Wang, Smith & Hyndman
13
+ 2006, DOI 10.1007/s10618-005-0039-x; strength form per FPP3 sec. 4.3 (the F_S >= 0.64 threshold drives
14
+ ``nsdiffs``). Re-implemented here so seasonality does not import stationarity.
15
+ * MSTL - multiple-seasonal STL. Bandara, Hyndman & Bergmeir 2021, arXiv:2107.13462. ``statsmodels.MSTL``.
16
+ * Seasonal subseries - the per-phase means + the per-phase series; the standard "month plot" (Cleveland &
17
+ Terpenning 1982, JASA 77(377), DOI 10.1080/01621459.1982.10477766).
18
+
19
+ NOTE: ``scipy.signal.cwt`` is DEPRECATED (1.12) and REMOVED (1.15); wavelet/scalogram seasonality uses
20
+ PyWavelets elsewhere. Here we stay on FFT-based spectral estimation, which scipy keeps.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+
26
+ import numpy as np
27
+
28
+
29
+ def _clean(x) -> np.ndarray:
30
+ a = np.asarray(x, dtype=float).ravel()
31
+ return a[np.isfinite(a)]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Spectrum:
36
+ """A spectral density estimate (periodogram or Welch) over positive frequencies, with the dominant peak."""
37
+
38
+ name: str
39
+ freqs: np.ndarray
40
+ power: np.ndarray
41
+ dominant_period: float | None
42
+ dominant_power: float
43
+ reference: str
44
+
45
+
46
+ def periodogram(x, fs: float = 1.0) -> Spectrum:
47
+ """Schuster periodogram; the dominant period is 1 / (the tallest non-DC frequency)."""
48
+ from scipy.signal import periodogram as _periodogram
49
+
50
+ a = _clean(x)
51
+ f, pxx = _periodogram(a, fs=fs)
52
+ if len(f) > 1:
53
+ idx = 1 + int(np.argmax(pxx[1:])) # skip the DC (zero-frequency) bin
54
+ dom_freq = float(f[idx])
55
+ dom_power = float(pxx[idx])
56
+ dom_period = (1.0 / dom_freq) if dom_freq > 0 else None
57
+ else:
58
+ dom_period, dom_power = None, 0.0
59
+ return Spectrum(
60
+ name="Periodogram", freqs=f, power=pxx, dominant_period=dom_period, dominant_power=dom_power,
61
+ reference="Schuster 1898, Terrestrial Magnetism 3(1):13-41; scipy.signal.periodogram",
62
+ )
63
+
64
+
65
+ def welch(x, fs: float = 1.0, nperseg: int | None = None) -> Spectrum:
66
+ """Welch's averaged periodogram (lower variance, coarser resolution) and its dominant period."""
67
+ from scipy.signal import welch as _welch
68
+
69
+ a = _clean(x)
70
+ nperseg = min(nperseg or 256, len(a))
71
+ f, pxx = _welch(a, fs=fs, nperseg=nperseg)
72
+ if len(f) > 1:
73
+ idx = 1 + int(np.argmax(pxx[1:]))
74
+ dom_freq = float(f[idx])
75
+ dom_power = float(pxx[idx])
76
+ dom_period = (1.0 / dom_freq) if dom_freq > 0 else None
77
+ else:
78
+ dom_period, dom_power = None, 0.0
79
+ return Spectrum(
80
+ name="Welch", freqs=f, power=pxx, dominant_period=dom_period, dominant_power=dom_power,
81
+ reference="Welch 1967, IEEE Trans. Audio Electroacoust. 15(2):70-73, DOI 10.1109/TAU.1967.1161901",
82
+ )
83
+
84
+
85
+ def seasonal_strength(x, period: int) -> float:
86
+ """F_S = max(0, 1 - Var(remainder)/Var(seasonal+remainder)) from a robust STL fit. 0 if too short."""
87
+ from statsmodels.tsa.seasonal import STL
88
+
89
+ a = _clean(x)
90
+ if period < 2 or len(a) < 2 * period:
91
+ return 0.0
92
+ res = STL(a, period=period, robust=True).fit()
93
+ var_r = float(np.var(res.resid))
94
+ var_sr = float(np.var(res.seasonal + res.resid))
95
+ if var_sr <= 0:
96
+ return 0.0
97
+ return float(max(0.0, 1.0 - var_r / var_sr))
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Decomp:
102
+ """An additive decomposition into trend + seasonal + remainder, with the trend/seasonal strength scores."""
103
+
104
+ trend: np.ndarray
105
+ seasonal: np.ndarray
106
+ remainder: np.ndarray
107
+ strength_trend: float
108
+ strength_seasonal: float
109
+ periods: tuple[int, ...]
110
+ reference: str
111
+
112
+
113
+ def _strengths(trend, seasonal, remainder) -> tuple[float, float]:
114
+ var_r = float(np.var(remainder)) or 1e-12
115
+ var_tr = float(np.var(trend + remainder)) or 1e-12
116
+ var_sr = float(np.var(seasonal + remainder)) or 1e-12
117
+ f_t = max(0.0, 1.0 - var_r / var_tr)
118
+ f_s = max(0.0, 1.0 - var_r / var_sr)
119
+ return f_t, f_s
120
+
121
+
122
+ def stl_decompose(x, period: int) -> Decomp:
123
+ """STL (Cleveland et al. 1990) additive decomposition at a single ``period``."""
124
+ from statsmodels.tsa.seasonal import STL
125
+
126
+ a = _clean(x)
127
+ if period < 2 or len(a) < 2 * period:
128
+ raise ValueError(f"STL needs len >= 2*period; got len={len(a)}, period={period}")
129
+ res = STL(a, period=period, robust=True).fit()
130
+ f_t, f_s = _strengths(res.trend, res.seasonal, res.resid)
131
+ return Decomp(
132
+ trend=np.asarray(res.trend, dtype=float), seasonal=np.asarray(res.seasonal, dtype=float),
133
+ remainder=np.asarray(res.resid, dtype=float), strength_trend=f_t, strength_seasonal=f_s,
134
+ periods=(int(period),),
135
+ reference="Cleveland, Cleveland, McRae & Terpenning 1990, J. Official Statistics 6(1):3-73 (STL)",
136
+ )
137
+
138
+
139
+ def mstl_decompose(x, periods: list[int]) -> Decomp:
140
+ """MSTL: multiple-seasonal STL (daily + weekly + ...) additive decomposition."""
141
+ from statsmodels.tsa.seasonal import MSTL
142
+
143
+ a = _clean(x)
144
+ periods = [int(p) for p in periods if int(p) >= 2]
145
+ if not periods:
146
+ raise ValueError("MSTL needs at least one period >= 2")
147
+ if len(a) < 2 * max(periods):
148
+ raise ValueError(f"MSTL needs len >= 2*max(periods); got len={len(a)}, max period={max(periods)}")
149
+ res = MSTL(a, periods=periods).fit()
150
+ seasonal_2d = np.asarray(res.seasonal, dtype=float)
151
+ # MSTL returns one seasonal column per period; sum them into a single additive seasonal component.
152
+ seasonal = seasonal_2d.sum(axis=1) if seasonal_2d.ndim == 2 else seasonal_2d
153
+ trend = np.asarray(res.trend, dtype=float)
154
+ remainder = np.asarray(res.resid, dtype=float)
155
+ f_t, f_s = _strengths(trend, seasonal, remainder)
156
+ return Decomp(
157
+ trend=trend, seasonal=seasonal, remainder=remainder, strength_trend=f_t, strength_seasonal=f_s,
158
+ periods=tuple(periods),
159
+ reference="Bandara, Hyndman & Bergmeir 2021, MSTL, arXiv:2107.13462; statsmodels.tsa.seasonal.MSTL",
160
+ )
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class SeasonalSubseries:
165
+ """The per-phase (per-season) means + the per-phase series, for the standard month/subseries plot."""
166
+
167
+ period: int
168
+ means: np.ndarray # length = period: the mean of each phase
169
+ profiles: list[np.ndarray] # one array per phase, each the values that landed in that phase
170
+
171
+
172
+ def seasonal_subseries(x, period: int) -> SeasonalSubseries:
173
+ """Split the series into cycles of length ``period``; return the per-phase mean and per-phase values."""
174
+ a = _clean(x)
175
+ if period < 2:
176
+ raise ValueError("period must be >= 2")
177
+ n_full = (len(a) // period) * period
178
+ if n_full == 0:
179
+ raise ValueError("series shorter than one period")
180
+ trimmed = a[:n_full]
181
+ cycles = trimmed.reshape(-1, period) # rows = cycles, cols = phases
182
+ means = cycles.mean(axis=0)
183
+ profiles = [cycles[:, j] for j in range(period)]
184
+ return SeasonalSubseries(period=int(period), means=means, profiles=profiles)
185
+
186
+
187
+ def seasonality_report(x, fs: float = 1.0, candidate_periods: list[int] | None = None) -> dict:
188
+ """Run the full seasonality panel: periodogram + Welch + per-candidate strength + (optional) MSTL."""
189
+ a = _clean(x)
190
+ pg = periodogram(a, fs=fs)
191
+ wl = welch(a, fs=fs)
192
+ candidates = candidate_periods or []
193
+ strengths = {int(p): float(seasonal_strength(a, int(p))) for p in candidates if int(p) >= 2}
194
+ out = {
195
+ "n": len(a),
196
+ "periodogram": {
197
+ "dominant_period": pg.dominant_period, "dominant_power": pg.dominant_power,
198
+ "freqs": pg.freqs.tolist(), "power": pg.power.tolist(), "reference": pg.reference,
199
+ },
200
+ "welch": {
201
+ "dominant_period": wl.dominant_period, "dominant_power": wl.dominant_power,
202
+ "reference": wl.reference,
203
+ },
204
+ "seasonal_strength_by_candidate_period": strengths,
205
+ }
206
+ # If the periodogram found a dominant period, also report STL strength at that period.
207
+ if pg.dominant_period is not None and pg.dominant_period >= 2:
208
+ p_int = round(pg.dominant_period)
209
+ if p_int >= 2 and len(a) >= 2 * p_int:
210
+ out["stl_at_dominant"] = _decomp_dict(stl_decompose(a, p_int))
211
+ return out
212
+
213
+
214
+ def _decomp_dict(d: Decomp) -> dict:
215
+ return {
216
+ "strength_trend": d.strength_trend, "strength_seasonal": d.strength_seasonal,
217
+ "periods": list(d.periods), "reference": d.reference,
218
+ }
@@ -0,0 +1,130 @@
1
+ """Retrospective sequence diagnostics on original, contiguous observations.
2
+
3
+ Matrix Profile/FLUSS: Yeh et al. 2016, DOI 10.1109/ICDM.2016.0179;
4
+ Gharghabi et al. 2017, DOI 10.1109/ICDM.2017.21 (STUMPY implementation).
5
+ Multiscale entropy: Costa et al. 2002, DOI 10.1103/PhysRevLett.89.068102.
6
+ Lempel-Ziv: Lempel & Ziv 1976, DOI 10.1109/TIT.1976.1055501.
7
+ Time reversal: the third-order lag statistic documented by tsfresh.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ import numpy as np
14
+
15
+
16
+ def finite_sequence(x) -> np.ndarray:
17
+ a = np.asarray(x, dtype=float)
18
+ if a.ndim != 1 or len(a) < 3 or not np.isfinite(a).all():
19
+ raise ValueError("A contiguous finite one-dimensional sequence of at least three observations is required")
20
+ return a
21
+
22
+
23
+ def matrix_profile(x, window: int = 32, regimes: int = 3) -> dict:
24
+ """Exact z-normalized self join; candidates are descriptive, never causal alarms."""
25
+ import stumpy
26
+
27
+ a = finite_sequence(x)
28
+ if not isinstance(window, int) or window < 3 or len(a) < 2 * window + 1:
29
+ raise ValueError("Matrix Profile requires an integer window >=3 and n >= 2*window+1")
30
+ if not isinstance(regimes, int) or regimes < 2:
31
+ raise ValueError("FLUSS requires at least two candidate regimes")
32
+ if np.std(a) == 0:
33
+ return {"available": False, "reason": "constant sequence has no discriminating shape profile",
34
+ "n": len(a), "window": window}
35
+ mp = stumpy.stump(a, m=window)
36
+ distances = np.asarray(mp.P_, dtype=float)
37
+ neighbors = np.asarray(mp.I_, dtype=int)
38
+ motifs = []
39
+ for i in np.argsort(distances, kind="stable"):
40
+ j = int(neighbors[i])
41
+ if np.isfinite(distances[i]) and j >= 0 and abs(int(i)-j) >= window:
42
+ motifs.append({"start": int(i), "match": j, "distance": float(distances[i])})
43
+ break
44
+ discords = []
45
+ for i in np.argsort(-distances, kind="stable"):
46
+ if np.isfinite(distances[i]) and all(abs(int(i)-d['start']) >= window for d in discords):
47
+ discords.append({"start": int(i), "distance": float(distances[i])})
48
+ if len(discords) == 3:
49
+ break
50
+ cac, boundaries = stumpy.fluss(neighbors, L=window, n_regimes=regimes, excl_factor=1)
51
+ return {"available": True, "n": len(a), "window": window,
52
+ "exclusion_zone": math.ceil(window / stumpy.config.STUMPY_EXCL_ZONE_DENOM),
53
+ "profile": [float(v) if np.isfinite(v) else None for v in distances],
54
+ "nearest_neighbor": neighbors.tolist(), "motifs": motifs, "discords": discords,
55
+ "corrected_arc_curve": np.asarray(cac, dtype=float).tolist(),
56
+ "regime_boundaries": sorted(int(b) for b in boundaries if 0 < int(b) < len(distances)),
57
+ "regimes_requested": regimes, "normalization": "per-window population-standard-deviation z scores",
58
+ "note": "Retrospective shape candidates, not anomaly probabilities or proven regime changes."}
59
+
60
+
61
+ def multiscale_entropy(x, scales=tuple(range(1, 11)), order: int = 2, tolerance_ratio: float = .15) -> dict:
62
+ """Costa coarse-graining, with original-series tolerance and no self matches.
63
+
64
+ Both m and m+1 templates use the same n-m start positions. Matching uses <= r
65
+ Chebyshev distance; finite-sample no-match estimates remain unavailable.
66
+ """
67
+ from scipy.spatial import cKDTree
68
+
69
+ a = finite_sequence(x)
70
+ if order < 1 or not isinstance(order, int) or not 0 < tolerance_ratio < 1:
71
+ raise ValueError("Invalid entropy order or tolerance ratio")
72
+ scales = tuple(scales)
73
+ if not scales or any(not isinstance(s, int) or s < 1 for s in scales) or len(set(scales)) != len(scales):
74
+ raise ValueError("Scales must be distinct positive integers")
75
+ tolerance = float(tolerance_ratio * np.std(a))
76
+ rows = []
77
+ for scale in scales:
78
+ coarse = a[:len(a)//scale*scale].reshape(-1, scale).mean(axis=1)
79
+ row = {"scale": scale, "n": len(coarse), "tolerance": tolerance, "entropy": None,
80
+ "matching_m": 0, "matching_m_plus_one": 0}
81
+ if len(coarse) < max(20, order + 3):
82
+ row["reason"] = "fewer than twenty coarse-grained observations"
83
+ else:
84
+ templates = np.lib.stride_tricks.sliding_window_view(coarse, order + 1)
85
+ counts = []
86
+ for dimension in (order, order + 1):
87
+ tree = cKDTree(templates[:, :dimension])
88
+ counts.append(int((tree.count_neighbors(tree, tolerance, p=np.inf) - len(templates)) // 2))
89
+ b, matches = counts
90
+ row.update(matching_m=b, matching_m_plus_one=matches)
91
+ if matches and b:
92
+ row["entropy"] = float(-np.log(matches / b))
93
+ else:
94
+ row["reason"] = "no matching templates; finite sample entropy is unavailable"
95
+ rows.append(row)
96
+ return {"order": order, "tolerance_ratio": tolerance_ratio, "tolerance_basis": "original population standard deviation",
97
+ "scales": rows}
98
+
99
+
100
+ def time_reversal(x, lag: int = 1) -> dict:
101
+ """E[x[t+2l]^2*x[t+l] - x[t+l]*x[t]^2], raw and standardized."""
102
+ a = finite_sequence(x)
103
+ if not isinstance(lag, int) or lag < 1 or len(a) <= 2 * lag:
104
+ raise ValueError("Time-reversal lag requires n > 2*lag")
105
+
106
+ def statistic(values):
107
+ return float(np.mean(values[lag:-lag] * (values[2*lag:]**2 - values[:-2*lag]**2)))
108
+
109
+ sd = np.std(a)
110
+ return {"lag": lag, "raw": statistic(a), "standardized": statistic((a-np.mean(a))/sd) if sd else 0.0,
111
+ "note": "A descriptive third-order statistic; no significance or irreversibility verdict is inferred."}
112
+
113
+
114
+ def sequence_report(x, seasonality: int = 1, window: int | None = None) -> dict:
115
+ """One JSON-ready contract for offline case diagnostics and the browser consumer."""
116
+ import antropy
117
+
118
+ a = finite_sequence(x)
119
+ chosen = window if window is not None else min(max(8, 2*seasonality), 64, len(a)//3)
120
+ profile = matrix_profile(a, window=chosen) if chosen >= 3 and len(a) >= 2*chosen+1 else {
121
+ "available": False, "reason": "sequence too short for the profile", "n": len(a), "window": chosen}
122
+ symbols = (a > np.median(a)).astype(int)
123
+ return {"n": len(a), "observation_indices": list(range(len(a))), "values": a.tolist(),
124
+ "matrix_profile": profile, "multiscale_entropy": multiscale_entropy(a),
125
+ "lempel_ziv": {"raw": int(antropy.lziv_complexity(symbols, normalize=False)),
126
+ "normalized": float(antropy.lziv_complexity(symbols, normalize=True)),
127
+ "threshold": float(np.median(a)), "alphabet": "0 if x<=median, 1 otherwise",
128
+ "normalization": "c(n)*log2(n)/n; finite samples can exceed one"},
129
+ "time_reversal": [time_reversal(a, lag) for lag in (1, 2, 7) if len(a) > 2*lag],
130
+ "interpretation": "Retrospective full-sequence diagnostics; not input to a forecast at an earlier origin."}