"""Tests for time-series synthesis (T1.3).""" import numpy as np import pytest from tsmm.data.synthesis import ( generate_series, inject_anomaly, couple_event, add_missing, ) # ─── generate_series ─────────────────────────────────────────────────────── def test_generate_series_shape(): s = generate_series(T=256, C=3, seed=0) assert s.shape == (256, 3) assert np.isfinite(s).all(), "baseline series must be finite (no NaN before inject)" def test_generate_series_reproducible(): a = generate_series(T=128, C=2, seed=7) b = generate_series(T=128, C=2, seed=7) assert np.array_equal(a, b) def test_generate_series_different_seed_differs(): a = generate_series(T=128, C=2, seed=1) b = generate_series(T=128, C=2, seed=2) assert not np.array_equal(a, b) def test_generate_series_has_structure(): # baseline + trend + seasonality + noise: variance should be > 0 and the # series should not be constant. s = generate_series(T=512, C=2, seed=3) assert np.std(s) > 0 assert np.ptp(s) > 0 # peak-to-peak # ─── inject_anomaly ──────────────────────────────────────────────────────── def test_inject_anomaly_returns_series_and_labels_and_segments(): s = generate_series(T=256, C=2, seed=0) out, labels, segments = inject_anomaly(s, types=["spike"], seed=0) assert out.shape == s.shape assert labels.shape == (256,) assert set(np.unique(labels)).issubset({0, 1}) assert isinstance(segments, list) def test_inject_anomaly_labels_cover_segments(): s = generate_series(T=256, C=2, seed=0) out, labels, segments = inject_anomaly(s, types=["level_shift", "variance_change"], seed=1) # Each segment must mark a contiguous run of label==1 exactly matching [start,end) for seg in segments: st, en = seg["start"], seg["end"] assert 0 <= st < en <= len(labels) assert labels[st:en].all(), f"segment {seg} not fully labeled" # immediately outside (if exists) must be 0 if st > 0: assert labels[st - 1] == 0 if en < len(labels): assert labels[en] == 0 def test_inject_anomaly_changes_the_series_at_segments(): s = generate_series(T=256, C=2, seed=0) out, labels, segments = inject_anomaly(s, types=["spike"], seed=0) assert np.any(labels == 1), "expected at least one anomaly segment" # at least somewhere the injected series differs from baseline assert not np.allclose(out, s) def test_inject_anomaly_each_supported_type(): s = generate_series(T=256, C=2, seed=0) for t in ["spike", "level_shift", "variance_change", "missing_segment", "drift"]: out, labels, segments = inject_anomaly(s, types=[t], seed=0) # missing_segment may place NaN, so use labels to assert effect assert np.any(labels == 1), f"type {t} produced no anomaly segment" def test_inject_anomaly_missing_segment_inserts_nan(): s = generate_series(T=256, C=2, seed=0) out, labels, segments = inject_anomaly(s, types=["missing_segment"], seed=0) assert np.isnan(out).any(), "missing_segment must inject NaN values" def test_inject_anomaly_reproducible(): s = generate_series(T=256, C=2, seed=0) o1, l1, _ = inject_anomaly(s, types=["spike", "level_shift"], seed=5) o2, l2, _ = inject_anomaly(s, types=["spike", "level_shift"], seed=5) assert np.array_equal(o1, o2) and np.array_equal(l1, l2) # ─── couple_event ────────────────────────────────────────────────────────── def test_couple_event_introduces_step_and_returns_text(): s = generate_series(T=256, C=2, seed=0) before = s.copy() out, text = couple_event(s, t=128, kind="deploy", seed=0) assert isinstance(text, str) and len(text) > 0 # a step change near t should alter the series magnitude after t assert not np.allclose(out[130:], before[130:]) # immediately before t the series is unchanged assert np.allclose(out[:128], before[:128]) def test_couple_event_step_has_persistent_offset(): s = generate_series(T=256, C=1, seed=0) out, _ = couple_event(s, t=100, kind="incident", seed=1) # mean of post-event window differs from pre-event baseline pre = out[:100].mean() post = out[120:].mean() assert abs(post - pre) > 1e-6 # ─── add_missing ─────────────────────────────────────────────────────────── def test_add_missing_introduces_nan_within_rate(): s = generate_series(T=512, C=2, seed=0) out = add_missing(s, rate=0.1, seed=0) assert out.shape == s.shape frac = np.isnan(out).mean() # within reasonable tolerance of requested rate assert 0.0 < frac <= 0.15 def test_add_missing_reproducible(): s = generate_series(T=256, C=2, seed=0) a = add_missing(s, rate=0.1, seed=3) b = add_missing(s, rate=0.1, seed=3) assert np.array_equal(a, b, equal_nan=True) # ─── regression: _random_window end >= start (found during 50k generation) ─ def test_inject_anomaly_end_ge_start_across_many_seeds(): """Regression: _random_window fallback used to draw end independently, producing end < start (ValueError in linspace). Sweep many seeds + types.""" import numpy as np s0 = generate_series(T=512, C=5, seed=0) for seed in range(300): for types in ( ["drift"], ["level_shift"], ["variance_change"], ["drift", "level_shift", "variance_change", "spike"], ): s = s0.copy() out, labels, segments = inject_anomaly(s, types=types, seed=seed) for seg in segments: assert seg["end"] >= seg["start"], (seed, types, seg) assert out.shape == s.shape assert labels.shape == (512,)