blob: 86483535112591f73e157bca449ad1bbfaec8b85 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Tests for time series generators.
These tests verify that the generators produce correct output with
expected properties and change points.
"""
import pytest
import numpy as np
from numpy.testing import assert_array_almost_equal, assert_allclose
from otava_test_data.generators.basic import (
constant,
noise_normal,
noise_uniform,
outlier,
step_function,
regression_fix,
TimeSeries,
ChangePoint,
)
from otava_test_data.generators.advanced import (
banding,
variance_change,
phase_change,
multiple_changes,
multiple_outliers,
multiple_variance_changes,
)
from otava_test_data.generators.combiner import (
combine,
add_noise,
CombinationGenerator,
)
# Parameterize with spec-defined lengths
LENGTHS = [50, 500]
class TestConstant:
"""Tests for constant time series generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_constant_length(self, length: int):
"""Constant series has correct length."""
ts = constant(length=length)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_constant_value(self, length: int):
"""All values in constant series are equal."""
ts = constant(length=length, value=42.0)
assert np.all(ts.data == 42.0)
@pytest.mark.parametrize("length", LENGTHS)
def test_constant_no_change_points(self, length: int):
"""Constant series has no change points."""
ts = constant(length=length)
assert len(ts.change_points) == 0
class TestNoiseNormal:
"""Tests for normally distributed noise generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_noise_normal_length(self, length: int):
"""Normal noise series has correct length."""
ts = noise_normal(length=length, seed=42)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_noise_normal_mean(self, length: int):
"""Normal noise has approximately correct mean."""
ts = noise_normal(length=length, mean=100.0, sigma=5.0, seed=42)
# With bounded noise, mean should be very close
assert abs(np.mean(ts.data) - 100.0) < 3 * 5.0 / np.sqrt(length)
@pytest.mark.parametrize("length", LENGTHS)
def test_noise_normal_bounded(self, length: int):
"""Bounded normal noise stays within 4 sigma."""
mean, sigma = 100.0, 5.0
ts = noise_normal(length=length, mean=mean, sigma=sigma, bounded=True, seed=42)
assert np.all(ts.data >= mean - 4 * sigma)
assert np.all(ts.data <= mean + 4 * sigma)
@pytest.mark.parametrize("length", LENGTHS)
def test_noise_normal_no_change_points(self, length: int):
"""Normal noise series has no change points."""
ts = noise_normal(length=length, seed=42)
assert len(ts.change_points) == 0
def test_noise_normal_reproducible(self):
"""Same seed produces same output."""
ts1 = noise_normal(length=100, seed=42)
ts2 = noise_normal(length=100, seed=42)
assert_array_almost_equal(ts1.data, ts2.data)
class TestNoiseUniform:
"""Tests for uniformly distributed noise generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_noise_uniform_length(self, length: int):
"""Uniform noise series has correct length."""
ts = noise_uniform(length=length, seed=42)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_noise_uniform_bounds(self, length: int):
"""Uniform noise stays within specified bounds."""
min_val, max_val = 90.0, 110.0
ts = noise_uniform(length=length, min_val=min_val, max_val=max_val, seed=42)
assert np.all(ts.data >= min_val)
assert np.all(ts.data <= max_val)
@pytest.mark.parametrize("length", LENGTHS)
def test_noise_uniform_no_change_points(self, length: int):
"""Uniform noise series has no change points."""
ts = noise_uniform(length=length, seed=42)
assert len(ts.change_points) == 0
class TestOutlier:
"""Tests for single outlier generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_outlier_length(self, length: int):
"""Outlier series has correct length."""
ts = outlier(length=length)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_outlier_position(self, length: int):
"""Outlier is at correct position."""
idx = length // 3
ts = outlier(length=length, baseline=100.0, outlier_value=150.0, outlier_index=idx)
assert ts.data[idx] == 150.0
assert ts.data[idx - 1] == 100.0
assert ts.data[idx + 1] == 100.0
@pytest.mark.parametrize("length", LENGTHS)
def test_outlier_change_point(self, length: int):
"""Outlier has one change point marked."""
ts = outlier(length=length)
assert len(ts.change_points) == 1
assert ts.change_points[0].change_type == "outlier"
def test_outlier_with_noise(self):
"""Outlier still stands out with noise added."""
ts = outlier(length=100, baseline=100.0, outlier_value=200.0, sigma=5.0, seed=42)
idx = ts.change_points[0].index
# Outlier should still be the maximum
assert ts.data[idx] == 200.0 # Outlier value is exact, not affected by noise
def test_outlier_index_validation(self):
"""Invalid outlier index raises error."""
with pytest.raises(ValueError):
outlier(length=100, outlier_index=100)
with pytest.raises(ValueError):
outlier(length=100, outlier_index=-1)
class TestStepFunction:
"""Tests for step function (single change point) generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_step_length(self, length: int):
"""Step function has correct length."""
ts = step_function(length=length)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_step_values(self, length: int):
"""Step function has correct values before and after."""
idx = length // 2
ts = step_function(
length=length, value_before=100.0, value_after=120.0, change_index=idx
)
assert np.all(ts.data[:idx] == 100.0)
assert np.all(ts.data[idx:] == 120.0)
@pytest.mark.parametrize("length", LENGTHS)
def test_step_change_point(self, length: int):
"""Step function has one change point."""
ts = step_function(length=length)
assert len(ts.change_points) == 1
assert ts.change_points[0].change_type == "step"
def test_step_negative_direction(self):
"""Step can go down (improvement in latency)."""
ts = step_function(length=100, value_before=120.0, value_after=100.0)
cp = ts.change_points[0]
assert cp.before_value > cp.after_value
class TestRegressionFix:
"""Tests for regression + fix generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_regression_fix_length(self, length: int):
"""Regression fix series has correct length."""
ts = regression_fix(length=length)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_regression_fix_two_change_points(self, length: int):
"""Regression fix has exactly two change points."""
ts = regression_fix(length=length)
assert len(ts.change_points) == 2
assert ts.change_points[0].change_type == "regression_start"
assert ts.change_points[1].change_type == "regression_end"
def test_regression_fix_special_case_same_value(self):
"""Special case: x1 == x3 (fixed back to original)."""
ts = regression_fix(
length=100,
value_normal=100.0,
value_regression=130.0,
value_fixed=100.0, # Same as value_normal
)
# First and last segments should have same value
start = ts.change_points[0].index
end = ts.change_points[1].index
assert ts.data[0] == ts.data[-1]
def test_regression_fix_min_duration(self):
"""Regression must have at least 2 points."""
with pytest.raises(ValueError):
regression_fix(length=100, regression_duration=1)
class TestBanding:
"""Tests for banding pattern generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_banding_length(self, length: int):
"""Banding series has correct length."""
ts = banding(length=length, seed=42)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_banding_only_two_values(self, length: int):
"""Banding without noise has only two distinct values."""
ts = banding(length=length, value1=100.0, value2=105.0, sigma=0.0, seed=42)
unique_values = np.unique(ts.data)
assert len(unique_values) == 2
assert set(unique_values) == {100.0, 105.0}
@pytest.mark.parametrize("length", LENGTHS)
def test_banding_no_change_points(self, length: int):
"""Banding has no explicit change points (it's noise)."""
ts = banding(length=length, seed=42)
assert len(ts.change_points) == 0
class TestVarianceChange:
"""Tests for variance change generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_variance_change_length(self, length: int):
"""Variance change series has correct length."""
ts = variance_change(length=length, seed=42)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_variance_change_one_change_point(self, length: int):
"""Variance change has one change point."""
ts = variance_change(length=length, seed=42)
assert len(ts.change_points) == 1
assert ts.change_points[0].change_type == "variance"
def test_variance_change_detectable(self):
"""Variance change is statistically detectable."""
ts = variance_change(
length=500,
mean=100.0,
sigma_before=2.0,
sigma_after=10.0,
change_index=250,
seed=42,
)
std_before = np.std(ts.data[:250])
std_after = np.std(ts.data[250:])
# After should have higher variance
assert std_after > std_before * 2
class TestPhaseChange:
"""Tests for phase change generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_phase_change_length(self, length: int):
"""Phase change series has correct length."""
ts = phase_change(length=length, seed=42)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_phase_change_one_change_point(self, length: int):
"""Phase change has one change point."""
ts = phase_change(length=length, seed=42)
assert len(ts.change_points) == 1
assert ts.change_points[0].change_type == "phase"
class TestMultipleChanges:
"""Tests for multiple consecutive changes generator."""
@pytest.mark.parametrize("length", LENGTHS)
def test_multiple_changes_length(self, length: int):
"""Multiple changes series has correct length."""
ts = multiple_changes(length=length)
assert len(ts) == length
@pytest.mark.parametrize("length", LENGTHS)
def test_multiple_changes_default_three(self, length: int):
"""Default multiple changes has three change points (4 values)."""
ts = multiple_changes(length=length)
assert len(ts.change_points) == 3
def test_multiple_changes_custom_values(self):
"""Custom values create correct number of change points."""
values = [100, 110, 120, 130, 140] # 5 values = 4 changes
ts = multiple_changes(length=500, values=values)
assert len(ts.change_points) == 4
class TestCombine:
"""Tests for time series combination functions."""
def test_combine_add(self):
"""Combining with addition works correctly."""
ts1 = constant(length=100, value=100.0)
ts2 = constant(length=100, value=10.0)
combined = combine(ts1, ts2, operation="add")
assert np.all(combined.data == 110.0)
def test_combine_merges_change_points(self):
"""Combining merges change points from all series."""
ts1 = step_function(length=100, change_index=30)
ts2 = outlier(length=100, outlier_index=70)
combined = combine(ts1, ts2, operation="add")
assert len(combined.change_points) == 2
def test_combine_requires_same_length(self):
"""Combining series of different lengths raises error."""
ts1 = constant(length=100)
ts2 = constant(length=200)
with pytest.raises(ValueError):
combine(ts1, ts2)
def test_add_noise_function(self):
"""add_noise helper adds noise correctly."""
ts = constant(length=100, value=100.0)
noisy = add_noise(ts, sigma=5.0, seed=42)
assert not np.all(noisy.data == 100.0) # Not constant anymore
assert abs(np.mean(noisy.data) - 100.0) < 2.0 # Mean still near 100
class TestCombinationGenerator:
"""Tests for the combination generator class."""
def test_generate_all_basic(self):
"""Generator produces all basic patterns."""
gen = CombinationGenerator(lengths=[50], seed=42)
results = gen.generate_all_basic()
# Should have all 10 basic generators
assert len(results) >= 10
def test_generate_with_noise_variants(self):
"""Generator produces noise variants."""
gen = CombinationGenerator(lengths=[50], seed=42)
results = gen.generate_with_noise_variants(sigmas=[0.0, 5.0])
# Should have multiple variants
assert len(results) > 10
def test_generate_pairwise_combinations(self):
"""Generator produces pairwise combinations."""
gen = CombinationGenerator(lengths=[50], seed=42)
results = gen.generate_pairwise_combinations()
# Should have combinations of 6 patterns = 6*5/2 = 15
assert len(results) >= 10
class TestTimeSeries:
"""Tests for TimeSeries dataclass."""
def test_to_csv(self, tmp_path):
"""TimeSeries can be exported to CSV."""
ts = step_function(length=100)
filepath = tmp_path / "test.csv"
ts.to_csv(str(filepath))
assert filepath.exists()
# Verify content
import pandas as pd
df = pd.read_csv(filepath)
assert len(df) == 100
assert "value" in df.columns
assert "is_change_point" in df.columns
def test_get_change_point_indices(self):
"""get_change_point_indices returns correct indices."""
ts = regression_fix(length=100, regression_start=30, regression_duration=20)
indices = ts.get_change_point_indices()
assert indices == [30, 50]