Skip to content

Commit 75c286e

Browse files
committed
test(resampler): add comprehensive tests for EventResampler
Add test suite for `EventResampler` covering window initialization, boundary conditions, and alignment behavior. Tests are parametrized to verify correct behavior with and without `align_to` configuration. Changes: - Added tests for `EventResampler` initialization and window end calculation - Added tests for sample processing before, at, and after window boundaries - Added tests for correct behavior when samples cross multiple windows - Added tests verifying window alignment is maintained through simple addition - Added key test demonstrating no data loss at window boundaries Tests use parametrized fixtures to cover both aligned and non-aligned window scenarios, ensuring the event-driven window management works correctly in all cases. Signed-off-by: Malte Schaaf <malte.schaaf@frequenz.com>
1 parent 20615e7 commit 75c286e

1 file changed

Lines changed: 298 additions & 0 deletions

File tree

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
# License: MIT
2+
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the `EventResampler` class."""
5+
6+
from dataclasses import dataclass
7+
from datetime import datetime, timedelta, timezone
8+
from unittest.mock import AsyncMock, patch
9+
10+
import pytest
11+
from frequenz.quantities import Quantity
12+
13+
from frequenz.sdk.timeseries import Sample
14+
from frequenz.sdk.timeseries._resampling._config import ResamplerConfig
15+
from frequenz.sdk.timeseries._resampling._event_resampler import EventResampler
16+
from frequenz.sdk.timeseries._resampling._resampler import Resampler
17+
18+
# pylint: disable=protected-access
19+
20+
21+
@dataclass
22+
class ResamplerTestCase:
23+
"""Data class for holding test case parameters for EventResampler tests."""
24+
25+
align_to: datetime | None
26+
"""Alignment point for windows. If None, windows are aligned to the first sample time."""
27+
28+
first_window_end: datetime
29+
"""Expected end time of the first window based on the configuration and start time."""
30+
31+
32+
@pytest.fixture
33+
def now() -> datetime:
34+
"""Fixture providing a fixed current time for testing."""
35+
return datetime(2024, 1, 1, 12, 0, 5, tzinfo=timezone.utc)
36+
37+
38+
@pytest.fixture(
39+
params=[
40+
ResamplerTestCase(
41+
align_to=None,
42+
first_window_end=datetime(2024, 1, 1, 12, 0, 15, tzinfo=timezone.utc),
43+
),
44+
ResamplerTestCase(
45+
align_to=datetime(1970, 1, 1, tzinfo=timezone.utc),
46+
first_window_end=datetime(2024, 1, 1, 12, 0, 10, tzinfo=timezone.utc),
47+
),
48+
],
49+
ids=["no_alignment", "with_alignment"],
50+
)
51+
def resampler_case(request: pytest.FixtureRequest) -> ResamplerTestCase:
52+
"""Fixture for EventResampler test cases."""
53+
assert isinstance(request.param, ResamplerTestCase)
54+
return request.param
55+
56+
57+
@pytest.fixture
58+
def resampler_config(resampler_case: ResamplerTestCase) -> ResamplerConfig:
59+
"""Create a basic resampler config for testing."""
60+
return ResamplerConfig(
61+
resampling_period=timedelta(seconds=10),
62+
max_data_age_in_periods=1,
63+
align_to=resampler_case.align_to,
64+
)
65+
66+
67+
@pytest.fixture
68+
def first_window_end(resampler_case: ResamplerTestCase) -> datetime:
69+
"""Fixture providing the expected first window end time based on the test case."""
70+
return resampler_case.first_window_end
71+
72+
73+
@patch("frequenz.sdk.timeseries._resampling._event_resampler.datetime")
74+
@pytest.mark.asyncio
75+
async def test_event_resampler_initialization(
76+
mock_datetime: AsyncMock,
77+
resampler_config: ResamplerConfig,
78+
now: datetime,
79+
first_window_end: datetime,
80+
) -> None:
81+
"""Event Resampler initializes without errors."""
82+
mock_datetime.now.return_value = now
83+
resampler = EventResampler(resampler_config)
84+
85+
assert resampler.config == resampler_config
86+
assert len(resampler._resamplers) == 0
87+
assert resampler._window_end == first_window_end
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_event_resampler_inherits_from_resampler(
92+
resampler_config: ResamplerConfig,
93+
) -> None:
94+
"""Event Resampler is a Resampler subclass."""
95+
resampler = EventResampler(resampler_config)
96+
assert isinstance(resampler, Resampler)
97+
assert hasattr(resampler, "add_timeseries")
98+
assert hasattr(resampler, "remove_timeseries")
99+
assert callable(resampler.add_timeseries)
100+
assert callable(resampler.remove_timeseries)
101+
102+
103+
@patch("frequenz.sdk.timeseries._resampling._event_resampler.datetime")
104+
@pytest.mark.asyncio
105+
async def test_window_initialization(
106+
mock_datetime: AsyncMock,
107+
resampler_config: ResamplerConfig,
108+
now: datetime,
109+
first_window_end: datetime,
110+
) -> None:
111+
"""Window initializes correctly on first sample."""
112+
mock_datetime.now.return_value = now
113+
resampler = EventResampler(resampler_config)
114+
115+
assert resampler._window_end == first_window_end
116+
117+
sample = Sample(now, Quantity(42.0))
118+
await resampler._process_sample(sample)
119+
120+
assert resampler._window_end == first_window_end
121+
122+
123+
@patch("frequenz.sdk.timeseries._resampling._event_resampler.datetime")
124+
@pytest.mark.asyncio
125+
async def test_sample_before_first_window_boundary(
126+
mock_datetime: AsyncMock,
127+
resampler_config: ResamplerConfig,
128+
now: datetime,
129+
first_window_end: datetime,
130+
) -> None:
131+
"""Samples before window boundary don't trigger emit."""
132+
mock_datetime.now.return_value = now
133+
resampler = EventResampler(resampler_config)
134+
135+
with patch.object(
136+
resampler, "_emit_window", new_callable=AsyncMock
137+
) as mock_emit_window:
138+
# Sample 1
139+
sample1 = Sample(now + timedelta(seconds=1), Quantity(10.0))
140+
await resampler._process_sample(sample1)
141+
142+
assert resampler._window_end == first_window_end
143+
mock_emit_window.assert_not_called()
144+
145+
# Process sample still within first window
146+
sample2 = Sample(now + timedelta(seconds=3), Quantity(20.0))
147+
await resampler._process_sample(sample2)
148+
149+
assert resampler._window_end == first_window_end
150+
mock_emit_window.assert_not_called()
151+
152+
153+
@patch("frequenz.sdk.timeseries._resampling._event_resampler.datetime")
154+
@pytest.mark.asyncio
155+
async def test_sample_at_window_boundary_triggers_emit(
156+
mock_datetime: AsyncMock,
157+
resampler_config: ResamplerConfig,
158+
now: datetime,
159+
first_window_end: datetime,
160+
) -> None:
161+
"""Sample at window boundary triggers emit and opens new window."""
162+
mock_datetime.now.return_value = now
163+
resampler = EventResampler(resampler_config)
164+
165+
with patch.object(
166+
resampler, "_emit_window", new_callable=AsyncMock
167+
) as mock_emit_window:
168+
# Sample 1
169+
sample1 = Sample(now + timedelta(seconds=1), Quantity(10.0))
170+
await resampler._process_sample(sample1)
171+
172+
assert resampler._window_end == first_window_end
173+
mock_emit_window.assert_not_called()
174+
175+
# Sample 2 at boundary
176+
sample2 = Sample(now + timedelta(seconds=10), Quantity(20.0))
177+
await resampler._process_sample(sample2)
178+
179+
mock_emit_window.assert_called_once_with(first_window_end)
180+
assert resampler._window_end == first_window_end + timedelta(seconds=10)
181+
182+
183+
@patch("frequenz.sdk.timeseries._resampling._event_resampler.datetime")
184+
@pytest.mark.asyncio
185+
async def test_sample_after_window_boundary(
186+
mock_datetime: AsyncMock,
187+
resampler_config: ResamplerConfig,
188+
now: datetime,
189+
first_window_end: datetime,
190+
) -> None:
191+
"""Sample after window boundary triggers emit."""
192+
mock_datetime.now.return_value = now
193+
resampler = EventResampler(resampler_config)
194+
195+
with patch.object(
196+
resampler, "_emit_window", new_callable=AsyncMock
197+
) as mock_emit_window:
198+
# Sample 1
199+
sample1 = Sample(now + timedelta(seconds=1), Quantity(10.0))
200+
await resampler._process_sample(sample1)
201+
202+
assert resampler._window_end == first_window_end
203+
mock_emit_window.assert_not_called()
204+
205+
# Sample 2 at boundary
206+
sample2 = Sample(now + timedelta(seconds=11), Quantity(20.0))
207+
await resampler._process_sample(sample2)
208+
209+
mock_emit_window.assert_called_once_with(first_window_end)
210+
assert resampler._window_end == first_window_end + timedelta(seconds=10)
211+
212+
213+
@patch("frequenz.sdk.timeseries._resampling._event_resampler.datetime")
214+
@pytest.mark.asyncio
215+
async def test_sample_crossing_multiple_windows(
216+
mock_datetime: AsyncMock,
217+
resampler_config: ResamplerConfig,
218+
now: datetime,
219+
first_window_end: datetime,
220+
) -> None:
221+
"""Sample crossing multiple windows emits each one."""
222+
mock_datetime.now.return_value = now
223+
resampler = EventResampler(resampler_config)
224+
225+
with patch.object(
226+
resampler, "_emit_window", new_callable=AsyncMock
227+
) as mock_emit_window:
228+
# Sample 1 at 2s
229+
sample1 = Sample(now + timedelta(seconds=2), Quantity(10.0))
230+
await resampler._process_sample(sample1)
231+
mock_emit_window.assert_not_called()
232+
assert resampler._window_end == first_window_end
233+
234+
# Sample 2 at 32s
235+
sample2 = Sample(now + timedelta(seconds=32), Quantity(20.0))
236+
await resampler._process_sample(sample2)
237+
assert mock_emit_window.call_count == 3
238+
assert resampler._window_end == first_window_end + timedelta(seconds=30)
239+
240+
241+
@patch("frequenz.sdk.timeseries._resampling._event_resampler.datetime")
242+
@pytest.mark.asyncio
243+
async def test_window_alignment_maintained(
244+
mock_datetime: AsyncMock,
245+
resampler_config: ResamplerConfig,
246+
now: datetime,
247+
first_window_end: datetime,
248+
) -> None:
249+
"""Windows remain aligned when using simple addition."""
250+
mock_datetime.now.return_value = now
251+
resampler = EventResampler(resampler_config)
252+
send_sequence = [1, 15, 25, 35] # Sample times in seconds
253+
254+
with patch.object(
255+
resampler, "_emit_window", new_callable=AsyncMock
256+
) as mock_emit_window:
257+
for offset in send_sequence:
258+
sample = Sample(now + timedelta(seconds=offset), Quantity(float(offset)))
259+
await resampler._process_sample(sample)
260+
261+
for i, call_args in enumerate(mock_emit_window.call_args_list):
262+
window_end = call_args.args[0] # Extract the first argument from the call
263+
expected = first_window_end + i * resampler_config.resampling_period
264+
assert window_end == expected
265+
266+
267+
@patch("frequenz.sdk.timeseries._resampling._event_resampler.datetime")
268+
@pytest.mark.asyncio
269+
async def test_key_benefit_no_data_loss_at_boundaries(
270+
mock_datetime: AsyncMock,
271+
resampler_config: ResamplerConfig,
272+
now: datetime,
273+
first_window_end: datetime,
274+
) -> None:
275+
"""
276+
Key benefit: No data loss at window boundaries.
277+
278+
This test demonstrates the main value of EventResampler compared
279+
to cascaded TimerResamplers: samples arriving at boundaries are
280+
never lost.
281+
"""
282+
mock_datetime.now.return_value = now
283+
resampler = EventResampler(resampler_config)
284+
arriving_samples = [1.0, 5.0, 9.5, 10.0, 10.1, 15.0, 20.0, 20.5]
285+
286+
with patch.object(
287+
resampler, "_emit_window", new_callable=AsyncMock
288+
) as mock_emit_window:
289+
for i, sample_offset in enumerate(arriving_samples):
290+
sample = Sample(now + timedelta(seconds=sample_offset), Quantity(i))
291+
await resampler._process_sample(sample)
292+
293+
assert mock_emit_window.call_count == 2
294+
assert mock_emit_window.call_args_list[0].args[0] == first_window_end
295+
assert mock_emit_window.call_args_list[1].args[
296+
0
297+
] == first_window_end + timedelta(seconds=10)
298+
assert resampler._window_end == (first_window_end + timedelta(seconds=20))

0 commit comments

Comments
 (0)