Files
ts-as-modality/tests/test_synthesis.py
T
张宗平 e2c0756a78 feat(m1): synthesis — components + anomaly/event/missing (T1.3)
- generate_series: baseline + trend + seasonality + noise (opt heavy-tail)
- inject_anomaly: spike / level_shift / variance_change / missing_segment / drift
  * non-overlapping segments (TDD caught an overlap bug) -> labels exactly
    match contiguous [start,end) runs
  * returns (series, point labels, segment metadata)
- couple_event: persistent per-channel step at t + event text
- add_missing: NaN drop at given rate
- tests/test_synthesis.py (14 tests, RED->GREEN; fixed overlap regression)

Exit criteria: seed-deterministic; labels cover segments; event step persistent.
2026-06-29 23:25:08 +08:00

130 lines
5.2 KiB
Python

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