| """zenfro_v7 β lattice prior for 4096-context β 64-step forecast skill. |
| |
| A deterministic mixture of dynamical stems, domain slices (retail, weather, |
| grid load), and structure-algebra productions (layer / segment / nest / |
| lead-lag / short-horizon / phrase-tile). Exact fractional noise and |
| forecastable event timing live inside families rather than as extra slots. |
| |
| Contract: one ``np.random.default_rng(seed)``, NumPy/SciPy only, finite |
| bounded rows via ``_sanitize``. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from collections.abc import Iterator |
| from functools import lru_cache, partial |
| from pathlib import Path |
| from queue import Full, Queue |
| from threading import Event, Thread |
|
|
| import numpy as np |
| from scipy.signal import lfilter |
|
|
| from cascade.interface import DataGenerator |
|
|
| |
| |
| _CHUNK = 2304 |
| |
| _AR1_BLK = 40 |
|
|
| |
| |
| _SEASONAL_PERIODS = np.array( |
| [4, 7, 12, 15, 24, 30, 48, 52, 60, 90, 96, 144, 168, 183, 240, 288, |
| 336, 365, 672, 720, 730], |
| dtype=np.float64, |
| ) |
| _SEASONAL_PROBS = np.array( |
| [0.015, 0.12, 0.08, 0.025, 0.14, 0.025, 0.05, 0.025, 0.045, 0.015, |
| 0.07, 0.04, 0.12, 0.015, 0.04, 0.035, 0.025, 0.03, 0.025, 0.05, 0.015], |
| dtype=np.float64, |
| ) |
| _SEASONAL_PROBS /= _SEASONAL_PROBS.sum() |
|
|
| |
| |
| |
| _SEASONAL_PAIRS = np.array( |
| [[15, 60], [60, 240], [24, 168], [48, 336], [96, 672], [7, 365], |
| [12, 52], [24, 720]], |
| dtype=np.float64, |
| ) |
|
|
| |
| |
| |
| _FAMILIES: tuple[str, ...] = ( |
| "trend_seasonal_ar", |
| "regime_shift", |
| "multiplicative", |
| "ar2", |
| "integrated", |
| "threshold_ar", |
| "chaotic", |
| "spectral_gp", |
| "long_memory", |
| "ou_stochastic_vol", |
| "physical_sensors", |
| "seasonal_counts", |
| "intermittent", |
| "pulse_outlier", |
| "retail_demand", |
| "weather", |
| "grid_load", |
| "layered", |
| "segmented", |
| "carrier_mod", |
| "lead_lag", |
| "short_horizon", |
| "phrase_tile", |
| ) |
| _DEFAULT_WEIGHTS: dict[str, float] = { |
| |
| "trend_seasonal_ar": 0.09, |
| "regime_shift": 0.095, |
| "multiplicative": 0.05, |
| "ar2": 0.12, |
| "integrated": 0.09, |
| "threshold_ar": 0.03, |
| "chaotic": 0.008, |
| "spectral_gp": 0.045, |
| "long_memory": 0.05, |
| "ou_stochastic_vol": 0.075, |
| "physical_sensors": 0.008, |
| "seasonal_counts": 0.012, |
| "intermittent": 0.012, |
| "pulse_outlier": 0.015, |
| |
| "retail_demand": 0.06, |
| "weather": 0.03, |
| "grid_load": 0.04, |
| |
| "layered": 0.04, |
| "segmented": 0.025, |
| "carrier_mod": 0.025, |
| "lead_lag": 0.02, |
| "short_horizon": 0.04, |
| "phrase_tile": 0.02, |
| } |
|
|
|
|
| class Generator(DataGenerator): |
| """Lattice mixture generator. Submit as ``generator.Generator``.""" |
|
|
| def __init__(self, config_dir: str, *, seed: int) -> None: |
| cfg_path = Path(config_dir) / "config.json" |
| cfg = json.loads(cfg_path.read_text(encoding="utf-8")) if cfg_path.is_file() else {} |
| self._cfg = cfg |
| self._seed = int(seed) |
| self._min_len = int(cfg.get("min_length", 64)) |
| self._max_len = int(cfg.get("max_length", 4096)) |
| if self._min_len < 1 or self._max_len < self._min_len: |
| raise ValueError(f"invalid length band [{self._min_len}, {self._max_len}]") |
| weights = dict(_DEFAULT_WEIGHTS) |
| for k, v in dict(cfg.get("family_weights", {})).items(): |
| if k in weights: |
| weights[k] = float(v) |
| w = np.asarray([weights[f] for f in _FAMILIES], dtype=np.float64) |
| if not np.all(np.isfinite(w)) or w.min() < 0 or w.sum() <= 0: |
| raise ValueError("family_weights must be finite, non-negative, and not all zero") |
| self._weights = w / w.sum() |
| |
| self._tr_hi_frac = float(cfg.get("tr_hi_frac", 0.25)) |
| self._tr_exc_lo = float(cfg.get("tr_exc_lo", 0.4)) |
| self._tr_exc_hi = float(cfg.get("tr_exc_hi", 3.0)) |
| self._gr_exc_lo = float(cfg.get("gr_exc_lo", 0.3)) |
| self._gr_exc_hi = float(cfg.get("gr_exc_hi", 2.0)) |
| self._sa_clean_frac = float(cfg.get("sa_clean_frac", 0.4)) |
| self._sa_clean_lo = float(cfg.get("sa_clean_lo", 0.02)) |
| self._sa_clean_hi = float(cfg.get("sa_clean_hi", 0.12)) |
| self._integrated_heavy_frac = float( |
| cfg.get("integrated_heavy_frac", 0.25) |
| ) |
| self._integrated_sv_frac = float(cfg.get("integrated_sv_frac", 0.30)) |
| self._augment = dict(cfg.get("augment", {})) |
| for name, value in ( |
| ("integrated_heavy_frac", self._integrated_heavy_frac), |
| ("integrated_sv_frac", self._integrated_sv_frac), |
| ("augment.tsmixup", float(self._augment.get("tsmixup", 0.0))), |
| ("augment.pad_prefix", float(self._augment.get("pad_prefix", 0.0))), |
| ): |
| if not 0.0 <= value <= 1.0: |
| raise ValueError(f"{name} must be in [0, 1]") |
| |
| self._struct_depth = int(cfg.get("struct_depth", 3)) |
| self._struct_mul_frac = float(cfg.get("struct_mul_frac", 0.35)) |
| self._struct_cuts = int(cfg.get("struct_cuts", 2)) |
| self._struct_nest = float(cfg.get("struct_nest", 6.0)) |
| self._struct_lag_frac = float(cfg.get("struct_lag_frac", 0.08)) |
| self._struct_horizon = int(cfg.get("struct_horizon", 64)) |
| self._struct_motif_max = int(cfg.get("struct_motif_max", 96)) |
| self._artifact_scale = float(cfg.get("artifact_scale", 1.0)) |
|
|
| @property |
| def name(self) -> str: |
| return str(self._cfg.get("name", "zenfro-v7-lattice")) |
|
|
| def generate(self, n_series: int) -> Iterator[np.ndarray]: |
| |
| |
| |
| |
| |
| |
| |
| |
| if n_series <= 0: |
| return |
| rng = np.random.default_rng(self._seed) |
| max_len = self._max_len |
| |
| |
| builders = ( |
| partial(_trend_seasonal_ar, hi_frac=self._tr_hi_frac, |
| exc_lo=self._tr_exc_lo, exc_hi=self._tr_exc_hi, |
| clean_frac=self._sa_clean_frac, |
| clean_lo=self._sa_clean_lo, clean_hi=self._sa_clean_hi), |
| _regime_shift, |
| partial(_multiplicative, hi_frac=self._tr_hi_frac, |
| exc_lo=self._gr_exc_lo, exc_hi=self._gr_exc_hi), |
| _ar2, |
| partial( |
| _integrated, |
| heavy_frac=self._integrated_heavy_frac, |
| sv_frac=self._integrated_sv_frac, |
| ), |
| _threshold_ar, _chaotic, _spectral_gp, |
| _long_memory, _ou_stochastic_vol, _physical_sensors, |
| _seasonal_counts, _intermittent, _pulse_outlier, |
| _retail_demand, _weather, _grid_load, |
| partial(_layered, depth=self._struct_depth, mul_frac=self._struct_mul_frac, |
| hi_frac=self._tr_hi_frac, exc_lo=self._tr_exc_lo, exc_hi=self._tr_exc_hi), |
| partial(_segmented, n_cuts=self._struct_cuts, |
| hi_frac=self._tr_hi_frac, exc_lo=self._tr_exc_lo, exc_hi=self._tr_exc_hi), |
| partial(_carrier_mod, nest_ratio=self._struct_nest), |
| partial(_lead_lag, lag_frac=self._struct_lag_frac), |
| partial(_short_horizon, horizon=self._struct_horizon, |
| hi_frac=self._tr_hi_frac, exc_lo=self._tr_exc_lo, exc_hi=self._tr_exc_hi, |
| clean_frac=self._sa_clean_frac, clean_lo=self._sa_clean_lo, |
| clean_hi=self._sa_clean_hi), |
| partial(_phrase_tile, motif_max=self._struct_motif_max, |
| hi_frac=self._tr_hi_frac, exc_lo=self._tr_exc_lo, exc_hi=self._tr_exc_hi), |
| ) |
| |
| |
| queue: Queue[object] = Queue(maxsize=1) |
| stop = Event() |
| done = object() |
|
|
| def put(item: object) -> bool: |
| while not stop.is_set(): |
| try: |
| queue.put(item, timeout=0.1) |
| return True |
| except Full: |
| continue |
| return False |
|
|
| def produce() -> None: |
| try: |
| produced = 0 |
| while produced < n_series and not stop.is_set(): |
| |
| |
| lengths = rng.integers( |
| self._min_len, max_len + 1, size=_CHUNK |
| ) |
| fam_ids = rng.choice( |
| len(_FAMILIES), size=_CHUNK, p=self._weights |
| ) |
| chunk: list[np.ndarray | None] = [None] * _CHUNK |
| for fam in range(len(_FAMILIES)): |
| idx = np.nonzero(fam_ids == fam)[0] |
| if idx.size == 0: |
| continue |
| block = builders[fam](rng, int(idx.size), max_len) |
| family = _FAMILIES[fam] |
| |
| preserve_nonnegative = family in { |
| "multiplicative", |
| "physical_sensors", |
| "seasonal_counts", |
| "intermittent", |
| "retail_demand", |
| "weather", |
| "grid_load", |
| } |
| if family == "retail_demand": |
| preserve_integers: bool | np.ndarray = np.all( |
| block == np.rint(block), axis=1 |
| ) |
| else: |
| preserve_integers = family in { |
| "seasonal_counts", |
| "intermittent", |
| } |
| |
| |
| |
| allow_reverse = fam in (0, 2, 7, 8) |
| |
| |
| allow_range = family != "integrated" |
| block = _sanitize( |
| _measurement_artifacts( |
| rng, |
| block, |
| preserve_nonnegative=preserve_nonnegative, |
| preserve_integers=preserve_integers, |
| allow_reverse=allow_reverse, |
| allow_range_artifacts=allow_range, |
| rate_scale=self._artifact_scale, |
| ) |
| ) |
| for row, series_i in enumerate(idx): |
| length = int(lengths[series_i]) |
| chunk[series_i] = np.ascontiguousarray( |
| block[row, :length], dtype=np.float64 |
| ) |
|
|
| |
| |
| |
| if self._min_len == max_len: |
| mix_rate = float(self._augment.get("tsmixup", 0.0)) |
| mixed = np.nonzero(rng.random(_CHUNK) < mix_rate)[0] |
| for series_i in mixed: |
| source = chunk[series_i] |
| if source is None: |
| continue |
| n_other = int(rng.integers(1, 3)) |
| others = rng.integers(0, _CHUNK, size=n_other) |
| weights = rng.dirichlet(np.ones(n_other + 1)) |
| combined = weights[0] * source |
| valid = True |
| for j, other_i in enumerate(others): |
| other = chunk[int(other_i)] |
| if other is None: |
| valid = False |
| break |
| combined = combined + weights[j + 1] * other |
| if valid: |
| chunk[series_i] = _sanitize(combined) |
|
|
| |
| |
| |
| pad_rate = float(self._augment.get("pad_prefix", 0.0)) |
| padded = np.nonzero(rng.random(_CHUNK) < pad_rate)[0] |
| for series_i in padded: |
| series = chunk[series_i] |
| if series is None or series.size < 8: |
| continue |
| cut = int(rng.integers(series.size // 8, 3 * series.size // 4)) |
| series[:cut] = series[cut] |
| take = min(_CHUNK, n_series - produced) |
| if not put((chunk, take)): |
| return |
| produced += take |
| except BaseException as exc: |
| put(exc) |
| finally: |
| put(done) |
|
|
| producer = Thread(target=produce, name="zenfro-v7-prefetch", daemon=True) |
| producer.start() |
| try: |
| while True: |
| item = queue.get() |
| if item is done: |
| break |
| if isinstance(item, BaseException): |
| raise item |
| chunk, take = item |
| for arr in chunk[:take]: |
| |
| if arr is None: |
| raise RuntimeError("internal: unfilled series slot") |
| yield arr |
| finally: |
| stop.set() |
| producer.join(timeout=1.0) |
|
|
|
|
| |
|
|
|
|
| def _ar1_batch( |
| innov: np.ndarray, phi: np.ndarray, S: int = _AR1_BLK |
| ) -> np.ndarray: |
| """AR(1) filter ``x[:,t] = phi*x[:,t-1] + innov[:,t]`` along time. |
| |
| Segmented (block) scan from ````: ~S + L/S Python iterations |
| instead of L, vectorised across the batch. Mathematically identical to the |
| sequential recurrence for ``phi β [0, 1)`` (FP relative error ~1e-13). |
| Falls back to SciPy ``lfilter`` per row when any ``|phi| >= 1``. |
| """ |
| n, L = innov.shape |
| p = np.asarray(phi, dtype=np.float64).reshape(n) |
| if np.any(np.abs(p) >= 1.0): |
| x = np.empty((n, L), dtype=np.float64) |
| for i in range(n): |
| x[i] = lfilter([1.0], [1.0, -float(p[i])], innov[i]) |
| return x |
| if L < 2 * S: |
| x = np.empty((n, L), dtype=np.float64) |
| x[:, 0] = innov[:, 0] |
| for t in range(1, L): |
| x[:, t] = p * x[:, t - 1] + innov[:, t] |
| return x |
| B = L // S |
| body = B * S |
| main = innov[:, :body].reshape(n, B, S) |
| y = np.empty((n, B, S), dtype=np.float64) |
| y[:, :, 0] = main[:, :, 0] |
| pcol = p[:, None] |
| for s in range(1, S): |
| y[:, :, s] = pcol * y[:, :, s - 1] + main[:, :, s] |
| r = p ** S |
| ylast = y[:, :, S - 1] |
| X = np.empty((n, B), dtype=np.float64) |
| X[:, 0] = ylast[:, 0] |
| for b in range(1, B): |
| X[:, b] = r * X[:, b - 1] + ylast[:, b] |
| carry_in = np.empty((n, B), dtype=np.float64) |
| carry_in[:, 0] = 0.0 |
| carry_in[:, 1:] = X[:, :-1] |
| ppow = p[:, None] ** np.arange(1, S + 1, dtype=np.float64)[None, :] |
| x = (y + carry_in[:, :, None] * ppow[:, None, :]).reshape(n, body) |
| if body == L: |
| return x |
| out = np.empty((n, L), dtype=np.float64) |
| out[:, :body] = x |
| prev = out[:, body - 1] |
| for t in range(body, L): |
| prev = p * prev + innov[:, t] |
| out[:, t] = prev |
| return out |
|
|
|
|
| def _ar2_batch(innov: np.ndarray, a1: np.ndarray, a2: np.ndarray) -> np.ndarray: |
| """AR(2) filter: ``x_t = a1 x_{t-1} + a2 x_{t-2} + e_t`` (batched over n).""" |
| n, L = innov.shape |
| x = np.empty((n, L), dtype=np.float64) |
| for i in range(n): |
| x[i] = lfilter( |
| [1.0], [1.0, -float(a1[i]), -float(a2[i])], innov[i] |
| ) |
| return x |
|
|
|
|
| def _stationary_unit_ar1( |
| rng: np.random.Generator, phi: np.ndarray, L: int |
| ) -> np.ndarray: |
| """Exact zero-mean, unit-variance stationary Gaussian AR(1) rows.""" |
| p = np.asarray(phi, dtype=np.float64).reshape(-1) |
| innov = rng.standard_normal((p.size, L)) * np.sqrt( |
| np.maximum(1.0 - p[:, None] * p[:, None], 1e-12) |
| ) |
| innov[:, 0] = rng.standard_normal(p.size) |
| return _ar1_batch(innov, p) |
|
|
|
|
| def _prefix_mean_std( |
| x: np.ndarray, *, calibration_points: int = 512 |
| ) -> tuple[np.ndarray, np.ndarray]: |
| """Location/scale from an initial calibration prefix only (causal).""" |
| prefix = x[:, : min(x.shape[1], calibration_points)] |
| mean = prefix.mean(axis=1, keepdims=True) |
| std = prefix.std(axis=1, keepdims=True) |
| return mean, np.where(std < 1e-12, 1.0, std) |
|
|
|
|
| def _prefix_standardize( |
| x: np.ndarray, *, center: bool = True, calibration_points: int = 512 |
| ) -> np.ndarray: |
| mean, std = _prefix_mean_std(x, calibration_points=calibration_points) |
| return (x - mean) / std if center else x / std |
|
|
|
|
| @lru_cache(maxsize=4) |
| def _seasonal_basis(L: int) -> tuple[np.ndarray, np.ndarray]: |
| """Cached unit sine/cosine waves for the fixed cadence bank.""" |
| angle = ( |
| 2.0 |
| * np.pi |
| * np.arange(L, dtype=np.float64)[None, :] |
| / _SEASONAL_PERIODS[:, None] |
| ) |
| return np.sin(angle), np.cos(angle) |
|
|
|
|
| def _seasonal(rng: np.random.Generator, n: int, L: int, k_max: int = 3) -> np.ndarray: |
| """Sum of 1..k_max stationary or slowly modulated seasonal components.""" |
| t = np.arange(L, dtype=np.float64)[None, :] |
| sin_basis, cos_basis = _seasonal_basis(L) |
| k = rng.integers(1, k_max + 1, size=n) |
| pair = _SEASONAL_PAIRS[ |
| rng.integers(0, len(_SEASONAL_PAIRS), size=n) |
| ] |
| use_pair = rng.random(n) < 0.35 |
| out = np.zeros((n, L), dtype=np.float64) |
| for j in range(k_max): |
| active = np.nonzero(k > j)[0] |
| per = rng.choice( |
| _SEASONAL_PERIODS, size=n, p=_SEASONAL_PROBS |
| ) |
| if j < 2: |
| per = np.where(use_pair, pair[:, j], per) |
| per = per[:, None] |
| amp = rng.uniform(0.2, 2.0, size=n)[:, None] |
| phase = rng.uniform(0.0, 2.0 * np.pi, size=n)[:, None] |
| |
| |
| |
| basis_idx = np.searchsorted(_SEASONAL_PERIODS, per[active, 0]) |
| component = amp[active] * ( |
| sin_basis[basis_idx] * np.cos(phase[active]) |
| + cos_basis[basis_idx] * np.sin(phase[active]) |
| ) |
| |
| |
| modulated = np.nonzero((k > j) & (rng.random(n) < 0.35))[0] |
| if modulated.size: |
| |
| modulated_local = np.searchsorted(active, modulated) |
| modulated_arg = ( |
| 2.0 * np.pi * t / per[modulated] + phase[modulated] |
| ) |
| m_per = np.clip( |
| per[modulated] * rng.uniform( |
| 4.0, 12.0, size=(modulated.size, 1) |
| ), |
| 32.0, |
| 2.0 * L, |
| ) |
| m_phase = rng.uniform( |
| 0.0, 2.0 * np.pi, size=(modulated.size, 1) |
| ) |
| slow = np.sin(2.0 * np.pi * t / m_per + m_phase) |
| amp_mod = 1.0 + rng.uniform( |
| 0.05, 0.45, size=(modulated.size, 1) |
| ) * slow |
| phase_mod = rng.uniform( |
| 0.05, 0.75, size=(modulated.size, 1) |
| ) * np.sin(2.0 * np.pi * t / (1.7 * m_per) - m_phase) |
| component[modulated_local] = ( |
| amp[modulated] |
| * amp_mod |
| * np.sin(modulated_arg + phase_mod) |
| ) |
| out[active] += component |
| return out |
|
|
|
|
| def _sparse_jumps(rng: np.random.Generator, n: int, L: int, rate: float, scale) -> np.ndarray: |
| """A (n, L) block of mostly-zero values with occasional N(0, scale) jumps. |
| |
| ``cumsum`` over this yields a piecewise-constant level; ``exp(cumsum)`` of a |
| scaled version yields a piecewise-constant positive multiplier. |
| """ |
| mask = rng.random((n, L)) < rate |
| mask[:, 0] = False |
| rows, cols = np.nonzero(mask) |
| jumps = np.zeros((n, L), dtype=np.float64) |
| if rows.size == 0: |
| return jumps |
| |
| |
| s = np.asarray(scale, dtype=np.float64) |
| event_scale = s if s.ndim == 0 else s.reshape(n)[rows] |
| jumps[rows, cols] = rng.normal(0.0, 1.0, size=rows.size) * event_scale |
| return jumps |
|
|
|
|
| def _rfft_standard_normal( |
| rng: np.random.Generator, n_rows: int, n_freq: int |
| ) -> np.ndarray: |
| """Gaussian coefficients for an even-length real inverse FFT. |
| |
| Interior complex bins have E|Z|Β²=1. DC and Nyquist are real N(0,1), as |
| required by Hermitian symmetry. |
| """ |
| z = ( |
| rng.standard_normal((n_rows, n_freq)) |
| + 1j * rng.standard_normal((n_rows, n_freq)) |
| ) / np.sqrt(2.0) |
| z[:, 0] = rng.standard_normal(n_rows) |
| z[:, -1] = rng.standard_normal(n_rows) |
| return z |
|
|
|
|
| def _normalized_logistic_curve( |
| tn: np.ndarray, midpoint: np.ndarray, steepness: np.ndarray |
| ) -> np.ndarray: |
| """Monotone sigmoid curves normalized to an exact [0, 1] excursion.""" |
| argument = np.clip(steepness * (tn - midpoint), -40.0, 40.0) |
| raw = 1.0 / (1.0 + np.exp(-argument)) |
| start = raw[:, :1] |
| span = np.maximum(raw[:, -1:] - start, 1e-12) |
| return (raw - start) / span |
|
|
|
|
| def _sawtooth_wave( |
| t: np.ndarray, |
| period: np.ndarray, |
| phase: np.ndarray, |
| flipped: np.ndarray, |
| ) -> np.ndarray: |
| """Centered periodic ramps with an abrupt reset in either direction.""" |
| cycle = np.mod(t / period + phase, 1.0) |
| return np.where(flipped, 1.0 - cycle, cycle) - 0.5 |
|
|
|
|
| def _row_standardize(x: np.ndarray) -> np.ndarray: |
| x = x - x.mean(axis=1, keepdims=True) |
| sd = x.std(axis=1, keepdims=True) |
| return x / np.where(sd < 1e-12, 1.0, sd) |
|
|
|
|
| def _measurement_artifacts( |
| rng: np.random.Generator, |
| block: np.ndarray, |
| *, |
| preserve_nonnegative: bool, |
| preserve_integers: bool | np.ndarray = False, |
| allow_reverse: bool = True, |
| allow_range_artifacts: bool = True, |
| rate_scale: float = 1.0, |
| ) -> np.ndarray: |
| """Apply sparse, cheap real-measurement effects to a generated block. |
| |
| Sparse measurement effects. Thresholds use an initial observed prefix so |
| history never depends on the unseen forecast window. ``rate_scale`` scales rates. |
| """ |
| original = np.asarray(block, dtype=np.float64) |
| out = original.copy() |
| n, L = out.shape |
| rs = float(np.clip(rate_scale, 0.0, 3.0)) |
| calibration_len = min(L, 512) |
|
|
| reverse = (rng.random(n) < (0.06 * rs)) if allow_reverse else np.zeros(n, dtype=bool) |
| out[reverse] = out[reverse, ::-1] |
|
|
| if not preserve_nonnegative: |
| invert = rng.random(n) < (0.04 * rs) |
| out[invert] *= -1.0 |
|
|
| |
| |
| for row in np.nonzero(rng.random(n) < (0.06 * rs))[0]: |
| q = float(rng.uniform(0.03, 0.18)) |
| upper = rng.random() < 0.5 |
| if not allow_range_artifacts: |
| continue |
| calibration = out[row, :calibration_len] |
| if upper: |
| threshold = np.quantile(calibration, 1.0 - q) |
| out[row] = np.minimum(out[row], threshold) |
| else: |
| threshold = np.quantile(calibration, q) |
| out[row] = np.maximum(out[row], threshold) |
|
|
| quantized = np.nonzero(rng.random(n) < (0.07 * rs))[0] |
| if quantized.size: |
| levels = rng.integers(16, 257, size=(quantized.size, 1)) |
| if allow_range_artifacts: |
| x = out[quantized] |
| calibration = x[:, :calibration_len] |
| lo = calibration.min(axis=1, keepdims=True) |
| hi = calibration.max(axis=1, keepdims=True) |
| step = (hi - lo) / np.maximum(levels - 1, 1) |
| safe_step = np.where(step < 1e-12, 1.0, step) |
| clipped = np.clip(x, lo, hi) |
| out[quantized] = ( |
| lo + np.rint((clipped - lo) / safe_step) * safe_step |
| ) |
|
|
| held = np.nonzero(rng.random(n) < (0.04 * rs))[0] |
| if held.size: |
| factors = rng.choice([2, 4, 8], size=held.size, p=[0.55, 0.30, 0.15]) |
| for factor in (2, 4, 8): |
| rows = held[factors == factor] |
| if rows.size: |
| out[rows] = np.repeat( |
| out[rows, ::factor], factor, axis=1 |
| )[:, :L] |
|
|
| if isinstance(preserve_integers, np.ndarray): |
| integer_rows = np.asarray(preserve_integers, dtype=bool).reshape(n) |
| out[integer_rows] = np.maximum(np.rint(out[integer_rows]), 0.0) |
| elif preserve_integers: |
| out = np.maximum(np.rint(out), 0.0) |
|
|
| degenerate = out[:, :calibration_len].std(axis=1) < 1e-9 |
| out[degenerate] = original[degenerate] |
| return out |
|
|
|
|
| |
|
|
|
|
| def _trend_seasonal_ar(rng: np.random.Generator, n: int, L: int, *, |
| hi_frac: float = 0.25, exc_lo: float = 0.4, |
| exc_hi: float = 3.0, clean_frac: float = 0.4, |
| clean_lo: float = 0.02, clean_hi: float = 0.12) -> np.ndarray: |
| t = np.arange(L, dtype=np.float64)[None, :] |
| level = rng.normal(0.0, 1.0, size=(n, 1)) |
| |
| _hi = rng.random((n, 1)) < hi_frac |
| exc = np.where(_hi, rng.normal(0.0, exc_hi, size=(n, 1)), |
| rng.normal(0.0, exc_lo, size=(n, 1))) |
| tn = t / max(L - 1, 1) |
| series = level + exc * tn + _seasonal(rng, n, L) |
| |
| cal = rng.random(n) < 0.28 |
| if cal.any(): |
| profile = rng.normal(0.0, 1.0, size=(n, 7)) |
| profile -= profile.mean(axis=1, keepdims=True) |
| phase = rng.integers(0, 7, size=(n, 1)) |
| day = (np.arange(L)[None, :] + phase) % 7 |
| weekly = np.take_along_axis(profile, day, axis=1) |
| amp = rng.uniform(0.05, 0.45, size=(n, 1)) |
| series = series + np.where(cal[:, None], amp * weekly, 0.0) |
| phi = rng.uniform(0.0, 0.85, size=n) |
| clean = rng.random((n, 1)) < clean_frac |
| sigma = np.where( |
| clean, |
| rng.uniform(clean_lo, clean_hi, size=(n, 1)), |
| rng.uniform(0.1, 0.6, size=(n, 1)), |
| ) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| return series + _ar1_batch(innov, phi) |
|
|
|
|
| def _regime_shift(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| level = np.cumsum(_sparse_jumps(rng, n, L, rate=3.0 / L, scale=2.0), axis=1) |
| log_vol = np.cumsum(_sparse_jumps(rng, n, L, rate=3.0 / L, scale=0.5), axis=1) |
| vol = np.exp(np.clip(log_vol, -3.0, 3.0)) * rng.uniform(0.1, 0.5, size=(n, 1)) |
| noise = rng.normal(0.0, 1.0, size=(n, L)) * vol |
| seas = _seasonal(rng, n, L, k_max=2) * rng.uniform(0.0, 1.0, size=(n, 1)) |
| |
| |
| |
| slope = rng.normal(0.0, 1.0 / L, size=(n, 1)) + np.cumsum( |
| _sparse_jumps(rng, n, L, rate=2.0 / L, scale=4.0 / L), axis=1 |
| ) |
| piecewise_trend = np.cumsum(slope, axis=1) |
| return level + piecewise_trend + seas + noise |
|
|
|
|
| def _multiplicative(rng: np.random.Generator, n: int, L: int, *, |
| hi_frac: float = 0.25, exc_lo: float = 0.3, exc_hi: float = 2.0) -> np.ndarray: |
| t = np.arange(L, dtype=np.float64)[None, :] |
| |
| _hg = rng.random((n, 1)) < hi_frac |
| gexc = np.where(_hg, rng.normal(0.0, exc_hi, size=(n, 1)), |
| rng.normal(0.0, exc_lo, size=(n, 1))) |
| tn = t / max(L - 1, 1) |
| base_level = np.exp(gexc * tn + rng.normal(0.0, 0.3, size=(n, 1))) |
| amp = rng.uniform(0.1, 0.6, size=(n, 1)) |
| seasonal_shape = _seasonal(rng, n, L, k_max=1) |
| seasonal_sd = seasonal_shape.std(axis=1, keepdims=True) |
| seasonal_shape /= np.where(seasonal_sd < 1e-12, 1.0, seasonal_sd) |
| seas = 1.0 + amp * seasonal_shape |
| noise = 1.0 + rng.normal(0.0, 1.0, size=(n, L)) * rng.uniform(0.02, 0.15, size=(n, 1)) |
| scale = rng.uniform(1.0, 50.0, size=(n, 1)) |
| return scale * base_level * np.clip(seas, 0.05, None) * np.clip(noise, 0.05, None) |
|
|
|
|
| def _ar2(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| |
| p1 = rng.uniform(0.3, 0.98, size=n) |
| p2 = rng.uniform(-0.6, 0.6, size=n) |
| a2 = p2 |
| a1 = p1 * (1.0 - p2) |
| sigma = rng.uniform(0.2, 0.8, size=(n, 1)) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| x = _ar2_batch(innov, a1, a2) |
| drift = rng.normal(0.0, 0.005, size=(n, 1)) * np.arange(L, dtype=np.float64)[None, :] |
| return x + drift |
|
|
|
|
| def _integrated( |
| rng: np.random.Generator, |
| n: int, |
| L: int, |
| *, |
| heavy_frac: float = 0.25, |
| sv_frac: float = 0.30, |
| ) -> np.ndarray: |
| """I(1)/I(2) paths with selective heavy tails and clustered volatility. |
| |
| The Gaussian baseline remains the majority. Heavy rows use variance-scaled |
| Student-t innovations, while stochastic-volatility rows receive a smooth |
| AR(1) log-vol multiplier. These mechanisms are applied inside an existing |
| integrated family without carving a separate mixture slot. |
| """ |
| order2 = rng.random(n) < 0.35 |
| drift = rng.normal(0.0, 0.02, size=(n, 1)) |
| sigma = rng.uniform(0.2, 1.0, size=(n, 1)) |
| eps = rng.normal(0.0, 1.0, size=(n, L)) |
| heavy = np.nonzero(rng.random(n) < heavy_frac)[0] |
| if heavy.size: |
| df = rng.uniform(3.0, 12.0, size=(heavy.size, 1)) |
| eps[heavy] = rng.standard_t(df, size=(heavy.size, L)) / np.sqrt( |
| df / (df - 2.0) |
| ) |
|
|
| stochastic = np.nonzero(rng.random(n) < sv_frac)[0] |
| if stochastic.size: |
| phi = 0.995 |
| log_vol = lfilter( |
| [1.0], |
| [1.0, -phi], |
| rng.standard_normal((stochastic.size, L)), |
| axis=1, |
| ) |
| log_vol -= log_vol.mean(axis=1, keepdims=True) |
| log_vol /= np.maximum(log_vol.std(axis=1, keepdims=True), 1e-9) |
| log_vol *= rng.uniform(0.10, 0.55, size=(stochastic.size, 1)) |
| eps[stochastic] *= np.exp(np.clip(log_vol, -2.0, 2.0)) |
|
|
| steps = eps * sigma + drift |
| walk = np.cumsum(steps, axis=1) |
| walk2 = np.cumsum(walk, axis=1) |
| o2 = order2[:, None] |
| |
| return np.where(o2, walk2 / max(L, 1) ** 0.5, walk) |
|
|
|
|
| def _threshold_ar(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| phi_hi = rng.uniform(0.3, 0.9, size=n) |
| phi_lo = rng.uniform(-0.9, 0.3, size=n) |
| const_hi = rng.normal(0.0, 0.3, size=n) |
| const_lo = rng.normal(0.0, 0.3, size=n) |
| sigma = rng.uniform(0.2, 0.7, size=(n, 1)) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| x = np.empty((n, L), dtype=np.float64) |
| x[:, 0] = innov[:, 0] |
| for t in range(1, L): |
| prev = x[:, t - 1] |
| hi = prev >= 0.0 |
| phi = np.where(hi, phi_hi, phi_lo) |
| const = np.where(hi, const_hi, const_lo) |
| x[:, t] = np.clip(const + phi * prev + innov[:, t], -1e6, 1e6) |
| return x |
|
|
|
|
| def _chaotic(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| |
| use_sine = rng.random(n) < 0.5 |
| r_log = rng.uniform(3.6, 4.0, size=n) |
| r_sin = rng.uniform(0.85, 1.0, size=n) |
| x0 = rng.uniform(0.05, 0.95, size=n) |
| x = np.empty((n, L), dtype=np.float64) |
| cur = x0.copy() |
| x[:, 0] = cur |
| for t in range(1, L): |
| nxt_log = r_log * cur * (1.0 - cur) |
| nxt_sin = r_sin * np.sin(np.pi * cur) |
| cur = np.where(use_sine, nxt_sin, nxt_log) |
| cur = np.clip(cur, 0.0, 1.0) |
| x[:, t] = cur |
| return x |
|
|
|
|
| def _spectral_gp(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Smooth stationary GP-like paths sampled in O(n L log L). |
| |
| An RBF kernel has a Gaussian spectral density. Drawing complex Fourier |
| coefficients under that envelope and applying one batched inverse FFT |
| preserves the useful smoothness/length-scale prior without the old |
| 48-pass cosine loop. |
| """ |
| f = np.fft.rfftfreq(L)[None, :] |
| lengthscale = np.exp(rng.uniform(np.log(8.0), np.log(256.0), size=(n, 1))) |
| envelope = np.exp(-0.5 * (2.0 * np.pi * lengthscale * f) ** 2) |
| z = rng.standard_normal((n, f.shape[1])) + 1j * rng.standard_normal((n, f.shape[1])) |
| z[:, 0] = 0.0 |
| x = np.fft.irfft(z * np.sqrt(envelope), n=L, axis=1) |
| sd = x.std(axis=1, keepdims=True) |
| return x / np.where(sd < 1e-12, 1.0, sd) |
|
|
|
|
| def _davies_harte_fgn( |
| rng: np.random.Generator, hurst: np.ndarray, L: int |
| ) -> np.ndarray: |
| """Exact fractional Gaussian noise via DaviesβHarte embedding. |
| |
| Covariance Ξ³(k)=0.5[(k+1)^(2H)-2k^(2H)+|k-1|^(2H)]. Embedding in a |
| ``2L`` circulant gives a real Gaussian sample with the requested |
| finite-lag covariance, unlike a generic ``1/f^Ξ²`` envelope. |
| """ |
| h = np.asarray(hurst, dtype=np.float64).reshape(-1, 1) |
| n = h.shape[0] |
| if n == 0: |
| return np.empty((0, L), dtype=np.float64) |
|
|
| k = np.arange(L, dtype=np.float64)[None, :] |
| power = 2.0 * h |
| covariance = 0.5 * ( |
| (k + 1.0) ** power |
| - 2.0 * k ** power |
| + np.abs(k - 1.0) ** power |
| ) |
| circulant = np.concatenate( |
| [covariance, np.zeros((n, 1)), covariance[:, 1:][:, ::-1]], axis=1 |
| ) |
| eigenvalues = np.maximum( |
| np.fft.rfft(circulant, axis=1).real, 0.0 |
| ) |
| z = _rfft_standard_normal(rng, n, eigenvalues.shape[1]) |
| return np.fft.irfft( |
| z * np.sqrt(eigenvalues), |
| n=2 * L, |
| axis=1, |
| norm="ortho", |
| )[:, :L] |
|
|
|
|
| def _long_memory(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Fractional power-law paths with both persistent and rough regimes. |
| |
| For Hurst H, fractional Gaussian noise has beta=2H-1. We sample that |
| stationary increment process, then cumulatively sum selected rows to obtain |
| mathematically consistent fractional Brownian motion paths. |
| |
| ~35% of rows use exact DaviesβHarte fGn (); the rest keep the |
| cheaper spectral envelope on a 2L embedding so the eval target after a |
| long context is not near an artificial wrap boundary. |
| """ |
| hurst = rng.uniform(0.3, 0.85, size=(n, 1)) |
| level_path = rng.random((n, 1)) < 0.40 |
| exact_core = rng.random(n) < 0.35 |
| approximate_rows = np.nonzero(~exact_core)[0] |
| exact_rows = np.nonzero(exact_core)[0] |
| x = np.empty((n, L), dtype=np.float64) |
| if approximate_rows.size: |
| embed_len = 2 * L |
| f = np.fft.rfftfreq(embed_len) |
| safe_f = np.maximum(f, 1.0 / embed_len)[None, :] |
| approximate_hurst = hurst[approximate_rows] |
| beta = 2.0 * approximate_hurst - 1.0 |
| amp = safe_f ** (-0.5 * beta) |
| count = approximate_rows.size |
| multiscale = rng.random((count, 1)) < 0.4 |
| split_idx = rng.integers(8, max(9, f.size // 3), size=(count, 1)) |
| split_f = np.maximum(split_idx / embed_len, 1.0 / embed_len) |
| beta_hi = 2.0 * rng.uniform(0.3, 0.8, size=(count, 1)) - 1.0 |
| above = np.arange(f.size)[None, :] > split_idx |
| amp_hi = split_f ** (-0.5 * beta) \ |
| * (safe_f / split_f) ** (-0.5 * beta_hi) |
| amp = np.where(multiscale & above, amp_hi, amp) |
| amp[:, 0] = 0.0 |
| z = _rfft_standard_normal(rng, count, f.size) |
| x[approximate_rows] = np.fft.irfft( |
| z * amp, n=embed_len, axis=1, norm="ortho" |
| )[:, :L] |
| if exact_rows.size: |
| x[exact_rows] = _davies_harte_fgn(rng, hurst[exact_rows], L) |
| if np.any(level_path): |
| level_rows = np.nonzero(level_path.reshape(-1))[0] |
| x[level_rows] = np.cumsum(x[level_rows], axis=1) |
| x[level_rows] -= x[level_rows, :1] |
| return _prefix_standardize(x) |
|
|
|
|
| def _ou_stochastic_vol(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Regime-switching mean reversion with bounded stochastic volatility. |
| |
| Regime-switching mean reversion with clustered volatility. Regime paths, |
| seasonal means, and heavy-tail masks are sampled in blocks; only the state |
| recurrence scans time, vectorised across rows. |
| """ |
| |
| |
| switch_rate = np.exp(rng.uniform(np.log(0.001), np.log(0.15), size=(n, 1))) |
| switches = rng.random((n, L)) < switch_rate |
| switches[:, 0] = rng.random(n) < 0.5 |
| regime = np.bitwise_and(np.cumsum(switches, axis=1), 1).astype(np.int8) |
|
|
| |
| |
| |
| slow = rng.random((n, 1)) < 0.5 |
| phi = np.where( |
| slow, |
| rng.uniform(0.995, 0.9995, size=(n, 1)), |
| rng.uniform(0.90, 0.99, size=(n, 1)), |
| ) |
| mu0 = rng.normal(-2.0, 1.0, size=(n, 1)) |
| mu1 = rng.normal(2.0, 1.0, size=(n, 1)) |
| mean = np.where(regime == 0, mu0, mu1) |
| seasonal_on = rng.random((n, 1)) < 0.6 |
| mean += seasonal_on * _seasonal(rng, n, L, k_max=3) \ |
| * rng.uniform(0.5, 3.0, size=(n, 1)) |
|
|
| sigma0 = rng.lognormal(np.log(0.3), 0.3, size=(n, 1)) |
| sigma1 = rng.lognormal(np.log(1.5), 0.5, size=(n, 1)) |
| base_sigma = np.where(regime == 0, sigma0, sigma1) |
| log_vol = np.cumsum( |
| _sparse_jumps(rng, n, L, rate=8.0 / L, scale=0.35), axis=1 |
| ) |
| log_vol -= log_vol.mean(axis=1, keepdims=True) |
| vol = base_sigma * np.exp(np.clip(log_vol, -1.5, 1.5)) |
|
|
| eps = rng.standard_normal((n, L)) |
| heavy = np.nonzero(rng.random(n) < 0.35)[0] |
| if heavy.size: |
| |
| |
| eps[heavy] = ( |
| rng.standard_t(4.0, size=(heavy.size, L)) / np.sqrt(2.0) |
| ) |
| shocks = rng.random((n, L)) < (3.0 / L) |
| shock_rows, shock_cols = np.nonzero(shocks) |
| |
| eps[shock_rows, shock_cols] += rng.normal( |
| 0.0, 5.0, size=shock_rows.size |
| ) |
|
|
| innovation_scale = np.sqrt(np.maximum(1.0 - phi * phi, 1e-6)) |
| drive = (1.0 - phi) * mean + innovation_scale * vol * eps |
| out = np.empty((n, L), dtype=np.float64) |
| out[:, 0] = mean[:, 0] + vol[:, 0] * eps[:, 0] |
| for i in range(n): |
| p = float(phi[i, 0]) |
| out[i, 1:] = lfilter( |
| [1.0], [1.0, -p], drive[i, 1:], zi=[p * out[i, 0]] |
| )[0] |
|
|
| scale = np.exp(rng.uniform(np.log(0.1), np.log(50.0), size=(n, 1))) |
| shift = rng.uniform(-100.0, 100.0, size=(n, 1)) |
| return out * scale + shift |
|
|
|
|
| def _physical_sensors(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Generic physical measurements without matching one private dataset. |
| |
| Four row-level archetypes cover smooth signed measurements, bounded |
| percentages, pressure-like wandering levels, and non-negative skewed |
| magnitudes. All share multi-cadence seasonality, smooth synoptic variation, |
| and sparse fronts/gusts. |
| """ |
| seasonal = _seasonal(rng, n, L, k_max=2) |
| smooth = _spectral_gp(rng, n, L) |
| fronts = np.cumsum( |
| _sparse_jumps(rng, n, L, rate=5.0 / L, scale=1.0), axis=1 |
| ) |
| base = ( |
| seasonal * rng.uniform(0.3, 2.0, size=(n, 1)) |
| + smooth * rng.uniform(0.2, 1.2, size=(n, 1)) |
| + fronts * rng.uniform(0.2, 1.0, size=(n, 1)) |
| ) |
|
|
| kind = rng.integers(0, 4, size=n) |
| out = base.copy() |
|
|
| bounded = kind == 1 |
| if bounded.any(): |
| gain = rng.uniform(0.8, 3.5, size=(int(bounded.sum()), 1)) |
| midpoint = rng.uniform(-0.8, 0.8, size=(int(bounded.sum()), 1)) |
| out[bounded] = 100.0 / (1.0 + np.exp(-gain * (base[bounded] - midpoint))) |
|
|
| pressure = kind == 2 |
| if pressure.any(): |
| count = int(pressure.sum()) |
| walk = np.cumsum(rng.standard_normal((count, L)), axis=1) / np.sqrt(L) |
| level = rng.uniform(900.0, 1100.0, size=(count, 1)) |
| out[pressure] = level + rng.uniform(2.0, 15.0, size=(count, 1)) * walk \ |
| + 2.0 * fronts[pressure] + 0.5 * seasonal[pressure] |
|
|
| magnitude = kind == 3 |
| if magnitude.any(): |
| count = int(magnitude.sum()) |
| gusts = (rng.random((count, L)) < (8.0 / L)) \ |
| * rng.lognormal(0.0, 0.8, size=(count, L)) |
| power = rng.uniform(1.0, 1.6, size=(count, 1)) |
| out[magnitude] = np.abs(base[magnitude]) ** power + gusts |
|
|
| return out |
|
|
|
|
| def _seasonal_counts(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Seasonal Poisson/negative-binomial counts with decaying bursts. |
| |
| This keeps count positivity and discreteness intact while covering |
| overdispersion, cadence-linked rate variation, slow signed growth, and |
| release/news-like bursts. Computation remains batched across rows. |
| """ |
| t = np.arange(L, dtype=np.float64)[None, :] |
| period = rng.choice( |
| _SEASONAL_PERIODS, size=(n, 1), p=_SEASONAL_PROBS |
| ) |
| phase = rng.uniform(0.0, 2.0 * np.pi, size=(n, 1)) |
| amp = rng.uniform(0.15, 0.8, size=(n, 1)) |
| log_rate = amp * np.sin(2.0 * np.pi * t / period + phase) |
| second = rng.random((n, 1)) < 0.55 |
| log_rate += second * (0.5 * amp) * np.sin( |
| 4.0 * np.pi * t / period + rng.uniform(0.0, 2.0 * np.pi, size=(n, 1)) |
| ) |
| |
| |
| calendar = rng.random((n, 1)) < 0.35 |
| day_period = rng.choice([24, 48, 96, 144], size=(n, 1)) |
| day_idx = (np.floor_divide(np.arange(L)[None, :], day_period) % 7).astype(np.int64) |
| day_factors = rng.normal(0.0, 0.12, size=(n, 7)) |
| day_factors[:, 5:] += rng.uniform(-0.8, 0.3, size=(n, 1)) |
| calendar_effect = np.take_along_axis(day_factors, day_idx, axis=1) |
| log_rate += calendar * calendar_effect |
| excursion = rng.uniform(-0.5, 0.5, size=(n, 1)) |
| log_rate += excursion * t / max(L - 1, 1) |
|
|
| |
| |
| impulses = ( |
| (rng.random((n, L)) < (2.0 / L)) |
| * rng.uniform(1.0, 10.0, size=(n, L)) |
| ) |
| burst = _ar1_batch(impulses, rng.uniform(0.85, 0.995, size=(n, 1))) |
| base = np.exp(rng.uniform(np.log(3.0), np.log(3000.0), size=(n, 1))) |
| lam = base * np.exp(np.clip(log_rate, -5.0, 5.0)) * (1.0 + burst) |
| np.clip(lam, 0.0, 1.0e7, out=lam) |
|
|
| |
| |
| overdispersed = rng.random((n, 1)) < 0.5 |
| shape = rng.uniform(0.5, 4.0, size=(n, 1)) |
| mixed = lam * rng.gamma(shape, 1.0 / shape, size=(n, L)) |
| return rng.poisson(np.where(overdispersed, mixed, lam)).astype(np.float64) |
|
|
|
|
| def _intermittent(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| t = np.arange(L, dtype=np.float64)[None, :] |
| base_p = rng.uniform(0.03, 0.35, size=(n, 1)) |
| period = rng.choice([7.0, 12.0, 24.0, 48.0, 168.0], size=(n, 1)) |
| season = rng.uniform(0.2, 1.2, size=(n, 1)) * np.sin( |
| 2.0 * np.pi * t / period + rng.uniform(0.0, 2.0 * np.pi, size=(n, 1)) |
| ) |
| |
| week = 0.55 + 0.45 * ( |
| 0.5 + 0.5 * np.sin( |
| 2.0 * np.pi * t / 7.0 |
| + rng.uniform(0.0, 2.0 * np.pi, size=(n, 1)) |
| ) |
| ) |
| occurrence_state = _stationary_unit_ar1( |
| rng, rng.uniform(0.0, 0.95, size=n), L |
| ) |
| occurrence_state = _prefix_standardize(occurrence_state) |
| logit = ( |
| np.log(base_p / (1.0 - base_p)) |
| + season |
| + np.log(np.clip(week, 0.2, 1.5)) |
| + rng.uniform(0.0, 1.2, size=(n, 1)) * occurrence_state |
| ) |
| p = 1.0 / (1.0 + np.exp(-logit)) |
| occur = (rng.random((n, L)) < p).astype(np.float64) |
|
|
| independent_size_state = _stationary_unit_ar1( |
| rng, rng.uniform(0.5, 0.98, size=n), L |
| ) |
| independent_size_state = _prefix_standardize(independent_size_state) |
| coupling = rng.uniform(0.15, 0.55, size=(n, 1)) |
| size_state = ( |
| coupling * occurrence_state |
| + np.sqrt(1.0 - coupling * coupling) * independent_size_state |
| ) |
| size_eta = rng.uniform(0.15, 0.45, size=(n, 1)) |
| size_factor = np.exp(size_eta * size_state - 0.5 * size_eta * size_eta) |
| magnitude = np.maximum(1.0, np.rint( |
| rng.gamma(shape=2.0, scale=1.0, size=(n, L)) |
| * rng.uniform(1.0, 10.0, size=(n, 1)) |
| * np.exp(0.25 * season) |
| * size_factor |
| )) |
| return occur * magnitude |
|
|
|
|
| def _retail_demand(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Weekly retail demand with persistent level drift and asymmetric events. |
| |
| Each row has a seven-day profile, optional adjacent-day suppression, a slow |
| latent log-demand walk, and short-lived promotions or stockouts. Mixes |
| integer counts with continuous non-negative magnitude series. |
| """ |
| t = np.arange(L, dtype=np.float64)[None, :] |
| tn = t / max(L - 1, 1) |
|
|
| profile = rng.normal(0.0, 1.0, size=(n, 7)) |
| profile -= profile.mean(axis=1, keepdims=True) |
| special_days = rng.random(n) < 0.55 |
| first_day = rng.integers(0, 7, size=n) |
| depth = rng.uniform(0.4, 1.6, size=n) |
| adjacent = np.zeros((n, 7), dtype=np.float64) |
| rows = np.arange(n) |
| adjacent[rows, first_day] -= depth |
| adjacent[rows, (first_day + 1) % 7] -= depth |
| adjacent -= adjacent.mean(axis=1, keepdims=True) |
| profile += np.where(special_days[:, None], adjacent, 0.0) |
| profile -= profile.mean(axis=1, keepdims=True) |
|
|
| phase = rng.integers(0, 7, size=(n, 1)) |
| day_index = (np.arange(L)[None, :] + phase) % 7 |
| weekly = ( |
| rng.uniform(0.03, 0.50, size=(n, 1)) |
| * np.take_along_axis(profile, day_index, axis=1) |
| ) |
|
|
| excursion = ( |
| rng.normal(0.0, 1.0, size=(n, 1)) |
| * rng.uniform(0.3, 2.5, size=(n, 1)) |
| ) |
| latent_raw = np.cumsum( |
| rng.normal(0.0, 1.0, size=(n, L)) |
| * rng.uniform(0.005, 0.05, size=(n, 1)), |
| axis=1, |
| ) |
| latent_walk = 3.0 * np.tanh(latent_raw / 3.0) |
|
|
| promotions = np.zeros((n, L), dtype=np.float64) |
| promo_rows, promo_cols = np.nonzero( |
| rng.random((n, L)) < (rng.uniform(1.0, 8.0, size=(n, 1)) / L) |
| ) |
| promotions[promo_rows, promo_cols] = ( |
| np.abs(rng.normal(0.0, 1.0, size=promo_rows.size)) |
| * rng.uniform(0.5, 2.5, size=n)[promo_rows] |
| ) |
| promo_echo = np.zeros_like(promotions) |
| promo_echo[:, 1:] = ( |
| promotions[:, :-1] * rng.uniform(0.2, 0.6, size=(n, 1)) |
| ) |
|
|
| stockouts = np.zeros((n, L), dtype=np.float64) |
| stock_rows, stock_cols = np.nonzero( |
| rng.random((n, L)) < (rng.uniform(0.0, 4.0, size=(n, 1)) / L) |
| ) |
| stockouts[stock_rows, stock_cols] = ( |
| np.abs(rng.normal(0.0, 1.0, size=stock_rows.size)) |
| * rng.uniform(0.3, 1.5, size=n)[stock_rows] |
| ) |
|
|
| noise = ( |
| rng.normal(0.0, 1.0, size=(n, L)) |
| * rng.uniform(0.02, 0.25, size=(n, 1)) |
| ) |
| base_log_level = rng.uniform(np.log(0.2), np.log(3000.0), size=(n, 1)) |
| log_mean = np.clip( |
| base_log_level |
| + excursion * tn |
| + latent_walk |
| + weekly |
| + promotions |
| + promo_echo |
| - stockouts |
| + noise, |
| -8.0, |
| 13.0, |
| ) |
| level = np.exp(log_mean) |
|
|
| count_rows = rng.random(n) < 0.35 |
| target_mean = np.exp( |
| rng.uniform(np.log(1.0), np.log(80.0), size=(n, 1)) |
| ) |
| calibration_mean, _ = _prefix_mean_std(level) |
| count_scale = target_mean / np.clip(calibration_mean, 1e-9, None) |
| counts = rng.poisson( |
| np.clip(level * count_scale, 0.0, 1.0e6) |
| ).astype(np.float64) |
| return np.where(count_rows[:, None], counts, level) |
|
|
|
|
| def _cloud(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """U-shaped [0, 100] cloud fraction via multi-timescale red noise + clip.""" |
| t = np.arange(L, dtype=np.float64)[None, :] |
| phi_s = rng.uniform(0.990, 0.9990, size=n) |
| syn = _ar1_batch(rng.normal(0.0, 1.0, (n, L)), phi_s) |
| syn = (syn - syn.mean(1, keepdims=True)) / (syn.std(1, keepdims=True) + 1e-9) |
| phi_m = rng.uniform(0.895, 0.95, size=n) |
| mid = _ar1_batch(rng.normal(0.0, 1.0, (n, L)), phi_m) |
| mid = (mid - mid.mean(1, keepdims=True)) / (mid.std(1, keepdims=True) + 1e-9) |
| phi_f = rng.uniform(0.75, 0.87, size=n) |
| fast = _ar1_batch(rng.normal(0.0, 1.0, (n, L)), phi_f) |
| fast = (fast - fast.mean(1, keepdims=True)) / (fast.std(1, keepdims=True) + 1e-9) |
| g = rng.normal(0.0, 1.0, (n, L)) |
| spike = (rng.random((n, L)) < 0.05) * rng.uniform(5.0, 11.0, (n, L)) |
| heavy = g * (1.0 + spike) |
| rough = heavy - 0.30 * np.concatenate([np.zeros((n, 1)), heavy[:, :-1]], axis=1) |
| rough = rough / (rough.std(1, keepdims=True) + 1e-9) |
| diur = np.sin(2.0 * np.pi * t / 24.0 + rng.uniform(0.0, 2.0 * np.pi, (n, 1))) |
| ws = 0.95 * rng.uniform(0.8, 1.2, (n, 1)) |
| wm = 0.82 * rng.uniform(0.7, 1.3, (n, 1)) |
| wf = 0.55 * rng.uniform(0.7, 1.3, (n, 1)) |
| wr = 0.30 * rng.uniform(0.7, 1.3, (n, 1)) |
| wd = 0.44 * rng.uniform(0.3, 1.4, (n, 1)) |
| u = ws * syn + wm * mid + wf * fast + wr * rough + wd * diur |
| centre = np.clip(rng.normal(61.0, 29.0, (n, 1)), 2.0, 98.0) |
| span = 57.0 * rng.uniform(0.82, 1.18, (n, 1)) |
| return np.round(np.clip(centre + span * u, 0.0, 100.0)) |
|
|
|
|
| def _weather(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Weather archetypes: diurnal base, night-floored radiation, and cloud cover. |
| |
| Cloud red-noise is expensive, so it only runs on a minority of weather rows. |
| Keep mixture weight low for multi-domain pools. |
| """ |
| t = np.arange(L, dtype=np.float64)[None, :] |
| rad_mask = rng.random(n) < 0.25 |
| cloud_mask = (rng.random(n) < 0.24) & ~rad_mask |
| wbp_mask = ~cloud_mask |
| out = np.empty((n, L), dtype=np.float64) |
|
|
| wbp_idx = np.nonzero(wbp_mask)[0] |
| m = wbp_idx.size |
| if m: |
| radb = rad_mask[wbp_idx][:, None] |
| level = rng.normal(0.0, 1.0, size=(m, 1)) |
| a1 = rng.uniform(0.55, 2.1, size=(m, 1)) |
| p1 = rng.uniform(0.0, 2.0 * np.pi, size=(m, 1)) |
| a2 = rng.uniform(0.12, 0.65, size=(m, 1)) |
| p2 = rng.uniform(0.0, 2.0 * np.pi, size=(m, 1)) |
| seas = ( |
| a1 * np.sin(2.0 * np.pi * t / 24.0 + p1) |
| + a2 * np.sin(2.0 * np.pi * t / 12.0 + p2) |
| ) |
| wk = (rng.random((m, 1)) < 0.45).astype(np.float64) |
| seas += wk * rng.uniform(0.12, 0.55, size=(m, 1)) * np.sin( |
| 2.0 * np.pi * t / 168.0 |
| + rng.uniform(0.0, 2.0 * np.pi, size=(m, 1)) |
| ) |
| month = (rng.random((m, 1)) < 0.35).astype(np.float64) |
| seas += month * rng.uniform(0.08, 0.40, size=(m, 1)) * np.sin( |
| 2.0 * np.pi * t / 720.0 |
| + rng.uniform(0.0, 2.0 * np.pi, size=(m, 1)) |
| ) |
| yamp = rng.uniform(0.25, 1.6, size=(m, 1)) |
| yper = rng.uniform(2000.0, 9000.0, size=(m, 1)) |
| yearly = yamp * np.sin( |
| 2.0 * np.pi * t / yper |
| + rng.uniform(0.0, 2.0 * np.pi, size=(m, 1)) |
| ) |
| phi = rng.uniform(0.65, 0.96, size=m) |
| sigma = rng.uniform(0.04, 0.18, size=(m, 1)) |
| noise = _ar1_batch(rng.normal(0.0, 1.0, size=(m, L)) * sigma, phi) |
| base = level + seas + yearly + noise |
| thr = rng.uniform(0.2, 0.6, size=(m, 1)) * a1 |
| ramp = rng.uniform(0.8, 1.6, size=(m, 1)) |
| rad_diurnal = ramp * np.maximum( |
| a1 * np.sin(2.0 * np.pi * t / 24.0 + p1) - thr, 0.0 |
| ) |
| night = rad_diurnal <= 0.0 |
| rad_series = ( |
| level + rad_diurnal + yearly |
| + np.where(night, noise * 0.12, noise) |
| ) |
| out[wbp_idx] = np.where(radb, rad_series, base) |
|
|
| cloud_idx = np.nonzero(cloud_mask)[0] |
| if cloud_idx.size: |
| out[cloud_idx] = _cloud(rng, int(cloud_idx.size), L) |
| return out |
|
|
|
|
| def _grid_load(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Non-negative electricity-style load with diurnal + weekly shape. |
| |
| Combines a smooth daily curve, a 7-day profile (weekend dip/lift), slow |
| demand drift, and mild AR noise. Optional evening peak skew matches |
| common regional load patterns without copying any private series. |
| """ |
| t = np.arange(L, dtype=np.float64)[None, :] |
| tn = t / max(L - 1, 1) |
|
|
| |
| phase = rng.uniform(0.0, 2.0 * np.pi, size=(n, 1)) |
| a1 = rng.uniform(0.35, 1.2, size=(n, 1)) |
| a2 = rng.uniform(0.05, 0.45, size=(n, 1)) * (rng.random((n, 1)) < 0.7) |
| diurnal = a1 * np.sin(2.0 * np.pi * t / 24.0 + phase) + a2 * np.sin( |
| 4.0 * np.pi * t / 24.0 + phase + rng.uniform(-0.4, 0.4, size=(n, 1)) |
| ) |
|
|
| |
| profile = rng.normal(0.0, 1.0, size=(n, 7)) |
| profile -= profile.mean(axis=1, keepdims=True) |
| weekend = rng.uniform(-1.2, 0.2, size=(n, 1)) |
| profile[:, 5:] += weekend |
| profile -= profile.mean(axis=1, keepdims=True) |
| day_phase = rng.integers(0, 7, size=(n, 1)) |
| day = (np.arange(L)[None, :] + day_phase) % 7 |
| weekly = rng.uniform(0.08, 0.40, size=(n, 1)) * np.take_along_axis( |
| profile, day, axis=1 |
| ) |
|
|
| excursion = rng.normal(0.0, 1.0, size=(n, 1)) * rng.uniform(0.2, 1.4, size=(n, 1)) |
| latent = np.cumsum( |
| rng.normal(0.0, 1.0, size=(n, L)) * rng.uniform(0.004, 0.03, size=(n, 1)), |
| axis=1, |
| ) |
| latent = 2.5 * np.tanh(latent / 2.5) |
|
|
| phi = rng.uniform(0.55, 0.95, size=n) |
| sigma = rng.uniform(0.03, 0.18, size=(n, 1)) |
| noise = _ar1_batch(rng.normal(0.0, 1.0, size=(n, L)) * sigma, phi) |
|
|
| |
| impulses = _sparse_jumps( |
| rng, n, L, rate=1.5 / L, scale=rng.uniform(0.4, 1.8, size=n) |
| ) |
| shocks = _ar1_batch(impulses, rng.uniform(0.80, 0.97, size=n)) |
|
|
| base = rng.uniform(np.log(80.0), np.log(8000.0), size=(n, 1)) |
| log_load = np.clip( |
| base + diurnal + weekly + excursion * tn + latent + noise + shocks, |
| np.log(1.0), |
| np.log(5.0e5), |
| ) |
| return np.exp(log_load) |
|
|
|
|
| def _pulse_event_mask( |
| rng: np.random.Generator, n: int, L: int |
| ) -> tuple[np.ndarray, np.ndarray]: |
| """Sample independent and history-dependent pulse occurrence processes. |
| |
| Kind 0 is a small calibration-only Poisson/Bernoulli branch. Kinds 1--3 |
| carry forecastable timing through repeated cadence, seasonal conditional |
| intensity, or self-excitation β useful for short-horizon event prediction. |
| """ |
| kind = rng.choice(4, size=n, p=[0.15, 0.40, 0.35, 0.10]) |
| events = np.zeros((n, L), dtype=bool) |
|
|
| independent = np.nonzero(kind == 0)[0] |
| if independent.size: |
| rate = rng.uniform(2.0, 6.0, size=(independent.size, 1)) / max(L, 1) |
| events[independent] = ( |
| rng.random((independent.size, L)) < np.minimum(rate, 0.35) |
| ) |
|
|
| periodic = np.nonzero(kind == 1)[0] |
| periods = rng.choice( |
| np.asarray([24, 48, 96, 168, 256, 336, 512]), |
| size=periodic.size, |
| p=np.asarray([0.10, 0.15, 0.20, 0.20, 0.15, 0.10, 0.10]), |
| ) |
| phases = np.asarray( |
| [rng.integers(0, max(int(period), 1)) for period in periods] |
| ) |
| for row, period, phase in zip(periodic, periods, phases, strict=True): |
| nominal = np.arange(int(phase), L, int(period)) |
| jitter = np.rint( |
| rng.normal(0.0, max(1.0, 0.04 * period), size=nominal.size) |
| ).astype(np.int64) |
| starts = np.clip(nominal + jitter, 1, L - 1) |
| events[row, starts] = True |
|
|
| seasonal = np.nonzero(kind == 2)[0] |
| if seasonal.size: |
| t = np.arange(L, dtype=np.float64)[None, :] |
| period = rng.choice( |
| np.asarray([24.0, 48.0, 96.0, 168.0, 336.0]), |
| size=(seasonal.size, 1), |
| ) |
| phase = rng.uniform(0.0, 2.0 * np.pi, size=(seasonal.size, 1)) |
| base_rate = rng.uniform(4.0, 16.0, size=(seasonal.size, 1)) / max(L, 1) |
| modulation = 0.15 + 1.70 * ( |
| 0.5 + 0.5 * np.sin(2.0 * np.pi * t / period + phase) |
| ) |
| probability = np.minimum(base_rate * modulation, 0.35) |
| events[seasonal] = rng.random((seasonal.size, L)) < probability |
|
|
| |
| |
| hawkes = np.nonzero(kind == 3)[0] |
| if hawkes.size: |
| baseline = rng.uniform(2.0, 8.0, size=hawkes.size) / max(L, 1) |
| decay = rng.uniform(0.70, 0.96, size=hawkes.size) |
| branching = rng.uniform(0.30, 0.80, size=hawkes.size) |
| excitation = np.zeros(hawkes.size, dtype=np.float64) |
| uniforms = rng.random((hawkes.size, L)) |
| for step in range(L): |
| occurred = uniforms[:, step] < np.minimum( |
| baseline + excitation, 0.35 |
| ) |
| events[hawkes, step] = occurred |
| excitation = ( |
| decay * excitation |
| + (1.0 - decay) * branching * occurred |
| ) |
|
|
| events[:, 0] = False |
| return events, kind |
|
|
|
|
| def _pulse_outlier(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| |
| |
| base = _spectral_gp(rng, n, L) * rng.uniform(0.5, 2.0, size=(n, 1)) |
| base += _seasonal(rng, n, L, k_max=1) * rng.uniform(0.0, 1.0, size=(n, 1)) |
|
|
| events, kind = _pulse_event_mask(rng, n, L) |
| magnitude_phi = rng.uniform(0.70, 0.98, size=n) |
| magnitude_state = _stationary_unit_ar1(rng, magnitude_phi, L) |
| magnitude_state = _prefix_standardize(magnitude_state) |
| magnitude = rng.uniform(2.0, 8.0, size=(n, 1)) * np.exp( |
| np.clip( |
| rng.uniform(0.10, 0.40, size=(n, 1)) * magnitude_state, |
| -1.0, |
| 1.0, |
| ) |
| ) |
| learnable_magnitude = ((kind == 1) | (kind == 2))[:, None] |
| magnitude_cycle = 1.0 + 0.25 * np.sin( |
| 2.0 |
| * np.pi |
| * np.arange(L, dtype=np.float64)[None, :] |
| / rng.choice( |
| np.asarray([96.0, 168.0, 336.0, 672.0]), size=(n, 1) |
| ) |
| + rng.uniform(0.0, 2.0 * np.pi, size=(n, 1)) |
| ) |
| magnitude *= np.where(learnable_magnitude, magnitude_cycle, 1.0) |
| sign = rng.choice(np.asarray([-1.0, 1.0]), size=(n, 1)) |
| impulses = events * sign * magnitude |
|
|
| recovery = _ar1_batch(impulses, rng.uniform(0.75, 0.995, size=n)) |
| sharp_shape = rng.random((n, 1)) < 0.45 |
| series = base + np.where(sharp_shape, impulses, recovery) |
|
|
| starts = rng.random((n, L)) < (2.0 / L) |
| starts[:, 0] = False |
| for row in range(n): |
| for start in np.nonzero(starts[row])[0]: |
| run = int(rng.integers(3, 65)) |
| end = min(int(start) + run, L) |
| series[row, start:end] = series[row, start - 1] |
| return series |
|
|
|
|
| |
| |
| |
| |
| def _stem_ar_seasonal(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| seas = _seasonal(rng, n, L, k_max=2) |
| phi = rng.uniform(0.1, 0.9, size=n) |
| sigma = rng.uniform(0.15, 0.7, size=(n, 1)) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| return _row_standardize(seas * rng.uniform(0.3, 1.2, size=(n, 1)) + _ar1_batch(innov, phi)) |
|
|
|
|
| def _stem_integrated(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| sigma = rng.uniform(0.2, 1.0, size=(n, 1)) |
| walk = np.cumsum(rng.normal(0.0, 1.0, size=(n, L)) * sigma, axis=1) |
| return _row_standardize(walk) |
|
|
|
|
| def _sample_stem(rng: np.random.Generator, n: int, L: int) -> np.ndarray: |
| """Draw one cheap stem family per row and fill a (n, L) block.""" |
| builders = ( |
| _stem_ar_seasonal, |
| _spectral_gp, |
| _long_memory, |
| _stem_integrated, |
| _ar2, |
| ) |
| fam = rng.integers(0, len(builders), size=n) |
| out = np.empty((n, L), dtype=np.float64) |
| for k, builder in enumerate(builders): |
| idx = np.nonzero(fam == k)[0] |
| if idx.size == 0: |
| continue |
| out[idx] = _row_standardize(builder(rng, int(idx.size), L)) |
| return out |
|
|
|
|
| def _layered(rng: np.random.Generator, n: int, L: int, *, |
| depth: int = 3, mul_frac: float = 0.35, |
| hi_frac: float = 0.25, exc_lo: float = 0.4, |
| exc_hi: float = 2.5) -> np.ndarray: |
| """Production: Stem β Stem [β Stem] β additive or multiplicative phrase. |
| |
| Each row stacks ``depth`` standardised stems. A minority use multiplicative |
| agreement (level Γ seasonal-like factor) rather than a single process. |
| """ |
| depth = int(np.clip(depth, 2, 4)) |
| |
| stems = [_sample_stem(rng, n, L) for _ in range(depth)] |
| n_active = rng.integers(2, depth + 1, size=n) |
|
|
| out = np.zeros((n, L), dtype=np.float64) |
| use_mul = rng.random(n) < mul_frac |
| for j, stem in enumerate(stems): |
| active = (n_active > j)[:, None] |
| w = rng.uniform(0.4, 1.6, size=(n, 1)) |
| |
| add_mask = active & (~use_mul[:, None]) |
| out = np.where(add_mask, out + w * stem, out) |
| |
| if j == 0: |
| out = np.where(use_mul[:, None], stem, out) |
| else: |
| factor = 1.0 + 0.35 * w * stem |
| out = np.where(active & use_mul[:, None], out * factor, out) |
|
|
| |
| |
| t = np.arange(L, dtype=np.float64)[None, :] / max(L - 1, 1) |
| _hi = rng.random((n, 1)) < hi_frac |
| exc = np.where(_hi, rng.normal(0.0, exc_hi * 0.5, size=(n, 1)), |
| rng.normal(0.0, exc_lo * 0.5, size=(n, 1))) |
| out = out + exc * t |
|
|
| |
| punct = rng.random(n) < 0.4 |
| if punct.any(): |
| jumps = np.cumsum( |
| _sparse_jumps(rng, n, L, rate=2.5 / L, scale=1.5), axis=1 |
| ) |
| out[punct] = out[punct] + jumps[punct] |
|
|
| scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1))) |
| shift = rng.uniform(-50.0, 50.0, size=(n, 1)) |
| return out * scale + shift |
|
|
|
|
| def _segmented(rng: np.random.Generator, n: int, L: int, *, |
| n_cuts: int = 2, hi_frac: float = 0.25, |
| exc_lo: float = 0.4, exc_hi: float = 2.5) -> np.ndarray: |
| """Production: Stem β Stem β clause boundaries splice different dynamics. |
| |
| Each clause is an independent stem; breakpoints change the generating law. |
| """ |
| n_cuts = int(np.clip(n_cuts, 1, 4)) |
| n_clauses = n_cuts + 1 |
| clauses = [_sample_stem(rng, n, L) for _ in range(n_clauses)] |
|
|
| |
| cuts = np.sort( |
| rng.integers(max(1, L // 8), max(2, (7 * L) // 8), size=(n, n_cuts)), |
| axis=1, |
| ) |
| |
| for c in range(1, n_cuts): |
| cuts[:, c] = np.maximum(cuts[:, c], cuts[:, c - 1] + max(8, L // 32)) |
| cuts = np.clip(cuts, 1, L - 2) |
|
|
| out = clauses[0].copy() |
| t_idx = np.arange(L)[None, :] |
| for c in range(n_cuts): |
| after = t_idx >= cuts[:, c:c + 1] |
| out = np.where(after, clauses[c + 1], out) |
|
|
| |
| |
| blend_w = max(4, L // 128) |
| for c in range(n_cuts): |
| cut = cuts[:, c:c + 1] |
| dist = (t_idx - cut).astype(np.float64) |
| gate = np.clip(0.5 + dist / (2.0 * blend_w), 0.0, 1.0) |
| left = clauses[c] |
| right = clauses[c + 1] |
| near = np.abs(dist) <= blend_w |
| blended = (1.0 - gate) * left + gate * right |
| out = np.where(near, blended, out) |
|
|
| |
| level_jump = rng.normal(0.0, 1.5, size=(n, n_cuts)) |
| for c in range(n_cuts): |
| after = t_idx >= cuts[:, c:c + 1] |
| out = np.where(after, out + level_jump[:, c:c + 1], out) |
|
|
| t = np.arange(L, dtype=np.float64)[None, :] / max(L - 1, 1) |
| _hi = rng.random((n, 1)) < hi_frac |
| exc = np.where(_hi, rng.normal(0.0, exc_hi * 0.4, size=(n, 1)), |
| rng.normal(0.0, exc_lo * 0.4, size=(n, 1))) |
| out = out + exc * t |
|
|
| scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1))) |
| shift = rng.uniform(-50.0, 50.0, size=(n, 1)) |
| return out * scale + shift |
|
|
|
|
| def _carrier_mod(rng: np.random.Generator, n: int, L: int, *, |
| nest_ratio: float = 6.0) -> np.ndarray: |
| """Production: Envelope β Carrier β slow scale nests a fast carrier. |
| |
| Smooth long-scale envelope modulates amplitude (and sometimes phase) of a |
| faster seasonal or AR carrier β multi-scale nesting for synoptic / cycle structure. |
| """ |
| nest_ratio = float(np.clip(nest_ratio, 2.0, 24.0)) |
| t = np.arange(L, dtype=np.float64)[None, :] |
|
|
| |
| use_gp_env = rng.random(n) < 0.55 |
| env = np.empty((n, L), dtype=np.float64) |
| gp_rows = np.nonzero(use_gp_env)[0] |
| sin_rows = np.nonzero(~use_gp_env)[0] |
| if gp_rows.size: |
| |
| f = np.fft.rfftfreq(L)[None, :] |
| lengthscale = np.exp( |
| rng.uniform(np.log(64.0), np.log(min(512.0, L / 2.0)), size=(gp_rows.size, 1)) |
| ) |
| envelope = np.exp(-0.5 * (2.0 * np.pi * lengthscale * f) ** 2) |
| z = rng.standard_normal((gp_rows.size, f.shape[1])) + 1j * rng.standard_normal( |
| (gp_rows.size, f.shape[1]) |
| ) |
| z[:, 0] = 0.0 |
| g = np.fft.irfft(z * np.sqrt(envelope), n=L, axis=1) |
| env[gp_rows] = _row_standardize(g) |
| if sin_rows.size: |
| per = rng.uniform(L / nest_ratio, L / 1.5, size=(sin_rows.size, 1)) |
| phase = rng.uniform(0.0, 2.0 * np.pi, size=(sin_rows.size, 1)) |
| env[sin_rows] = np.sin(2.0 * np.pi * t / per + phase) |
|
|
| |
| carrier = _seasonal(rng, n, L, k_max=3) |
| carrier = _row_standardize(carrier) |
| mix_ar = rng.random(n) < 0.45 |
| if mix_ar.any(): |
| ar = _row_standardize(_ar2(rng, n, L)) |
| w = rng.uniform(0.3, 0.7, size=(n, 1)) |
| carrier = np.where(mix_ar[:, None], w * carrier + (1.0 - w) * ar, carrier) |
|
|
| amp = 1.0 + rng.uniform(0.3, 1.4, size=(n, 1)) * env |
| |
| |
| carrier_lag = np.empty_like(carrier) |
| carrier_lag[:, 0] = carrier[:, 0] |
| carrier_lag[:, 1:] = carrier[:, :-1] |
| wobble = rng.uniform(0.0, 0.35, size=(n, 1)) * env |
| out = amp * (carrier + wobble * carrier_lag) |
|
|
| |
| sigma = rng.uniform(0.05, 0.35, size=(n, 1)) * (0.5 + 0.5 * np.abs(env)) |
| phi = rng.uniform(0.0, 0.8, size=n) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| out = out + _ar1_batch(innov, phi) |
|
|
| scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1))) |
| shift = rng.uniform(-50.0, 50.0, size=(n, 1)) |
| return out * scale + shift |
|
|
|
|
| def _delay_batch(x: np.ndarray, lags: np.ndarray) -> np.ndarray: |
| """Causal delay with edge hold, vectorised over a small lag vocabulary. |
| |
| Rows sharing a lag are shifted in one slice copy β O(#unique_lags) passes |
| instead of a Python loop over n. |
| """ |
| n, L = x.shape |
| out = np.empty_like(x) |
| |
| for lag in np.unique(lags): |
| rows = np.nonzero(lags == lag)[0] |
| if rows.size == 0: |
| continue |
| lag_i = int(lag) |
| block = x[rows] |
| delayed = np.empty_like(block) |
| delayed[:, :lag_i] = block[:, :1] |
| delayed[:, lag_i:] = block[:, :-lag_i] |
| out[rows] = delayed |
| return out |
|
|
|
|
| def _lead_lag(rng: np.random.Generator, n: int, L: int, *, |
| lag_frac: float = 0.08) -> np.ndarray: |
| """Production: Driver β· Response β lagged temporal causal chain. |
| |
| Univariate response driven by a latent driver with a drawn lag and |
| FIR-like coupling, plus its own AR residual β teaches lead-lag structure. |
| """ |
| lag_frac = float(np.clip(lag_frac, 0.01, 0.25)) |
| |
| lag_menu = np.unique( |
| np.clip( |
| (np.array([0.01, 0.02, 0.04, 0.06, 0.08, 0.12, 0.16, 0.20]) * L).astype(np.int64), |
| 1, |
| max(1, int(L * lag_frac)), |
| ) |
| ) |
| lags = rng.choice(lag_menu, size=n) |
|
|
| driver = _sample_stem(rng, n, L) |
| use_parent2 = rng.random(n) < 0.4 |
| parent2 = _sample_stem(rng, n, L) |
|
|
| a0 = rng.uniform(0.2, 1.2, size=(n, 1)) |
| a1 = rng.uniform(0.3, 1.5, size=(n, 1)) |
| b = rng.uniform(0.2, 1.0, size=(n, 1)) |
| lags2 = rng.choice(lag_menu, size=n) |
|
|
| delayed = _delay_batch(driver, lags) |
| resp = a0 * driver + a1 * delayed |
| if use_parent2.any(): |
| delayed2 = _delay_batch(parent2, lags2) |
| resp = np.where(use_parent2[:, None], resp + b * delayed2, resp) |
|
|
| phi = rng.uniform(0.2, 0.9, size=n) |
| sigma = rng.uniform(0.1, 0.5, size=(n, 1)) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| resp = resp + _ar1_batch(innov, phi) |
| seas_on = rng.random((n, 1)) < 0.5 |
| resp = resp + seas_on * _seasonal(rng, n, L, k_max=2) * rng.uniform( |
| 0.1, 0.8, size=(n, 1) |
| ) |
|
|
| scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1))) |
| shift = rng.uniform(-50.0, 50.0, size=(n, 1)) |
| return resp * scale + shift |
|
|
|
|
| def _short_horizon( |
| rng: np.random.Generator, |
| n: int, |
| L: int, |
| *, |
| horizon: int = 64, |
| hi_frac: float = 0.25, |
| exc_lo: float = 0.4, |
| exc_hi: float = 2.5, |
| clean_frac: float = 0.4, |
| clean_lo: float = 0.02, |
| clean_hi: float = 0.12, |
| ) -> np.ndarray: |
| """Production: Signal + short residual tuned to the eval forecast horizon. |
| |
| Smooth seasonal signal continuing across the short forecast window, plus |
| an AR residual whose correlation length is O(H) so noise averages inside |
| the window without erasing continuity. |
| """ |
| H = int(np.clip(horizon, 16, 256)) |
| t = np.arange(L, dtype=np.float64)[None, :] / max(L - 1, 1) |
|
|
| |
| signal = _seasonal(rng, n, L, k_max=3) |
| |
| mix_gp = rng.random(n) < 0.45 |
| if mix_gp.any(): |
| gp = _row_standardize(_spectral_gp(rng, n, L)) |
| w = rng.uniform(0.2, 0.55, size=(n, 1)) |
| signal = np.where(mix_gp[:, None], (1.0 - w) * signal + w * gp, signal) |
| signal = _row_standardize(signal) |
|
|
| _hi = rng.random((n, 1)) < hi_frac |
| exc = np.where( |
| _hi, |
| rng.normal(0.0, exc_hi, size=(n, 1)), |
| rng.normal(0.0, exc_lo, size=(n, 1)), |
| ) |
| signal = signal + exc * t |
|
|
| |
| |
| phi_h = float(np.exp(-1.0 / H)) |
| phi_h2 = float(np.exp(-1.0 / max(H // 2, 8))) |
| use_half = rng.random(n) < 0.40 |
| phi = np.where( |
| use_half, |
| rng.uniform(max(0.45, phi_h2 - 0.12), min(0.97, phi_h2 + 0.08), size=n), |
| rng.uniform(max(0.5, phi_h - 0.15), min(0.98, phi_h + 0.08), size=n), |
| ) |
| clean = rng.random((n, 1)) < clean_frac |
| sigma = np.where( |
| clean, |
| rng.uniform(clean_lo, clean_hi, size=(n, 1)), |
| rng.uniform(0.12, 0.55, size=(n, 1)), |
| ) |
| innov = rng.normal(0.0, 1.0, size=(n, L)) * sigma |
| residual = _ar1_batch(innov, phi) |
|
|
| |
| impulses = _sparse_jumps(rng, n, L, rate=1.5 / L, scale=rng.uniform(1.0, 4.0, size=n)) |
| recover_phi = rng.uniform(0.85, 0.98, size=n) |
| events = _ar1_batch(impulses, recover_phi) |
| use_events = rng.random(n) < 0.35 |
| residual = residual + use_events[:, None] * events |
|
|
| out = signal + residual |
| scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1))) |
| shift = rng.uniform(-50.0, 50.0, size=(n, 1)) |
| return out * scale + shift |
|
|
|
|
| def _phrase_tile( |
| rng: np.random.Generator, |
| n: int, |
| L: int, |
| *, |
| motif_max: int = 96, |
| hi_frac: float = 0.25, |
| exc_lo: float = 0.4, |
| exc_hi: float = 2.5, |
| ) -> np.ndarray: |
| """Production: Tile(local shape) β non-sinusoidal repeating phrases. |
| |
| Pure Fourier seasonality under-covers weekday/shift/ops motifs that are |
| shaped bumps, not sinusoids. Each row draws a short motif, tiles it across |
| L, and applies slow amplitude/level drift so consecutive periods remain |
| forecastable while still evolving. |
| """ |
| motif_max = int(np.clip(motif_max, 16, 256)) |
| |
| period_menu = np.array( |
| [7, 12, 16, 24, 32, 48, 64, 72, 96], dtype=np.int64 |
| ) |
| period_menu = period_menu[period_menu <= motif_max] |
| periods = rng.choice(period_menu, size=n) |
|
|
| t = np.arange(L, dtype=np.float64)[None, :] |
| out = np.empty((n, L), dtype=np.float64) |
| for p in np.unique(periods): |
| rows = np.nonzero(periods == p)[0] |
| m = int(rows.size) |
| p_i = int(p) |
| |
| kind = rng.integers(0, 5, size=m) |
| motif = np.empty((m, p_i), dtype=np.float64) |
| u = np.linspace(0.0, 1.0, p_i, endpoint=False)[None, :] |
|
|
| cos_rows = kind == 0 |
| if cos_rows.any(): |
| width = rng.uniform(0.15, 0.55, size=(int(cos_rows.sum()), 1)) |
| centre = rng.uniform(0.2, 0.8, size=(int(cos_rows.sum()), 1)) |
| motif[cos_rows] = np.maximum( |
| 0.0, np.cos(np.pi * (u - centre) / np.maximum(width, 1e-3)) |
| ) |
|
|
| tri_rows = kind == 1 |
| if tri_rows.any(): |
| peak = rng.uniform(0.2, 0.8, size=(int(tri_rows.sum()), 1)) |
| left = np.clip(u / np.maximum(peak, 1e-3), 0.0, 1.0) |
| right = np.clip((1.0 - u) / np.maximum(1.0 - peak, 1e-3), 0.0, 1.0) |
| motif[tri_rows] = np.minimum(left, right) |
|
|
| ar_rows = kind == 2 |
| if ar_rows.any(): |
| count = int(ar_rows.sum()) |
| phi = rng.uniform(0.3, 0.9, size=count) |
| innov = rng.normal(0.0, 1.0, size=(count, p_i)) |
| motif[ar_rows] = _ar1_batch(innov, phi) |
|
|
| saw_rows = kind == 3 |
| if saw_rows.any(): |
| count = int(saw_rows.sum()) |
| tt = np.arange(p_i, dtype=np.float64)[None, :] |
| period = np.full((count, 1), float(p_i)) |
| phase = rng.uniform(0.0, 1.0, size=(count, 1)) |
| flipped = rng.random((count, 1)) < 0.5 |
| motif[saw_rows] = _sawtooth_wave(tt, period, phase, flipped) |
|
|
| log_rows = kind == 4 |
| if log_rows.any(): |
| count = int(log_rows.sum()) |
| mid = rng.uniform(0.25, 0.75, size=(count, 1)) |
| steep = rng.uniform(6.0, 24.0, size=(count, 1)) |
| motif[log_rows] = _normalized_logistic_curve( |
| np.broadcast_to(u, (count, p_i)), mid, steep |
| ) |
|
|
| motif = _row_standardize(motif) |
| |
| reps = int(np.ceil(L / p_i)) |
| tiled = np.tile(motif, (1, reps))[:, :L] |
| |
| n_tiles = max(1, int(np.ceil(L / p_i))) |
| amp_path = np.cumsum( |
| rng.normal(0.0, 0.08, size=(m, n_tiles)), axis=1 |
| ) |
| amp_path = 1.0 + 0.35 * _row_standardize(amp_path) |
| level_path = np.cumsum( |
| rng.normal(0.0, 0.05, size=(m, n_tiles)), axis=1 |
| ) |
| tile_idx = np.minimum(np.arange(L) // p_i, n_tiles - 1) |
| amp = amp_path[:, tile_idx] |
| level = level_path[:, tile_idx] |
| |
| jitter = rng.normal(0.0, 0.05, size=(m, L)) |
| out[rows] = amp * tiled + level + jitter |
|
|
| _hi = rng.random((n, 1)) < hi_frac |
| exc = np.where( |
| _hi, |
| rng.normal(0.0, exc_hi * 0.5, size=(n, 1)), |
| rng.normal(0.0, exc_lo * 0.5, size=(n, 1)), |
| ) |
| tn = np.arange(L, dtype=np.float64)[None, :] / max(L - 1, 1) |
| out = out + exc * tn |
|
|
| |
| phi = rng.uniform(0.0, 0.7, size=n) |
| sigma = rng.uniform(0.05, 0.35, size=(n, 1)) |
| out = out + _ar1_batch(rng.normal(0.0, 1.0, size=(n, L)) * sigma, phi) |
|
|
| scale = np.exp(rng.uniform(np.log(0.2), np.log(40.0), size=(n, 1))) |
| shift = rng.uniform(-50.0, 50.0, size=(n, 1)) |
| return out * scale + shift |
|
|
|
|
|
|
|
|
| |
|
|
|
|
| def _sanitize(block: np.ndarray) -> np.ndarray: |
| """Guarantee finite float64 values and proportionally bound each row. |
| |
| The trainer's ``check_series`` rejects any non-finite value, which would |
| fail the whole run. Proportional rescaling preserves within-row geometry; |
| hard clipping can create artificial constant plateaus on explosive paths. |
| """ |
| x = np.asarray(block, dtype=np.float64) |
| np.nan_to_num(x, copy=False, nan=0.0, posinf=1e6, neginf=-1e6) |
| if x.ndim == 1: |
| peak = float(np.max(np.abs(x))) |
| if peak > 1e6: |
| x *= 1e6 / peak |
| else: |
| peak = np.max(np.abs(x), axis=1, keepdims=True) |
| scale = np.where(peak > 1e6, 1e6 / np.maximum(peak, 1e-12), 1.0) |
| x *= scale |
| return x |
|
|