77
88import asyncio
99import logging
10+ import math
1011from collections .abc import Sequence
11- from datetime import datetime , timedelta
12+ from datetime import datetime , timedelta , timezone
1213from typing import SupportsIndex , overload
1314
1415import numpy as np
15- from frequenz .channels import Receiver
16+ from frequenz .channels import Broadcast , Receiver , Sender
1617from numpy .typing import ArrayLike
1718
1819from .._internal .asyncio import cancel_and_await
1920from . import Sample
21+ from ._resampling import Resampler , ResamplerConfig
2022from ._ringbuffer import OrderedRingBuffer
2123
2224log = logging .getLogger (__name__ )
@@ -27,7 +29,7 @@ class MovingWindow:
2729 A data window that moves with the latest datapoints of a data stream.
2830
2931 After initialization the `MovingWindow` can be accessed by an integer
30- index or a timestamp. A sub window can be accessed by using a slice of integers
32+ index or a timestamp. A sub window can be accessed by using a slice of
3133 integers or timestamps.
3234
3335 Note that a numpy ndarray is returned and thus users can use
@@ -39,19 +41,31 @@ class MovingWindow:
3941 the point in time that defines the alignment can be outside of the time window.
4042 Modulo arithmetic is used to move the `window_alignment` timestamp into the
4143 latest window.
42- If for example the `window_alignment` parameter is set to `datetime(1, 1, 1)`
43- and the window size is bigger than one day then the first element will always
44- be aligned to the midnight. For further information see also the
44+ If for example the `window_alignment` parameter is set to
45+ `datetime(1, 1, 1, tzinfo=timezone.utc)` and the window size is bigger than
46+ one day then the first element will always be aligned to the midnight.
47+ For further information see also the
4548 [`OrderedRingBuffer`][frequenz.sdk.timeseries._ringbuffer.OrderedRingBuffer]
4649 documentation.
4750
51+ Resampling might be required to reduce the number of samples to store, and
52+ it can be set by specifying the resampler config parameter so that the user
53+ can control the granularity of the samples to be stored in the underlying
54+ buffer.
55+
56+ If resampling is not required, the resampler config parameter can be
57+ set to None in which case the MovingWindow will not perform any resampling.
4858
4959 **Example1** (calculating the mean of a time interval):
5060
5161 ```
52- window = MovingWindow(size=100, resampled_data_recv=resampled_data_recv)
62+ window = MovingWindow(
63+ size=timedelta(minutes=5),
64+ resampled_data_recv=resampled_data_recv,
65+ input_sampling_period=timedelta(seconds=1),
66+ )
5367
54- time_start = datetime.now()
68+ time_start = datetime.now(tz=timezone.utc )
5569 time_end = time_start + timedelta(minutes=5)
5670
5771 # ... wait for 5 minutes until the buffer is filled
@@ -70,24 +84,29 @@ class MovingWindow:
7084
7185 # create a window that stores two days of data
7286 # starting at 1.1.23 with samplerate=1
73- window = MovingWindow(size = (60 * 60 * 24 * 2), sample_receiver)
87+ window = MovingWindow(
88+ size=timedelta(days=2),
89+ resampled_data_recv=sample_receiver,
90+ input_sampling_period=timedelta(seconds=1),
91+ )
7492
7593 # wait for one full day until the buffer is filled
7694 asyncio.sleep(60*60*24)
7795
7896 # create a polars series with one full day of data
79- time_start = datetime(2023, 1, 1)
80- time_end = datetime(2023, 1, 2)
97+ time_start = datetime(2023, 1, 1, tzinfo=timezone.utc )
98+ time_end = datetime(2023, 1, 2, tzinfo=timezone.utc )
8199 s = pl.Series("Jan_1", mv[time_start:time_end])
82100 ```
83101 """
84102
85- def __init__ (
103+ def __init__ ( # pylint: disable=too-many-arguments
86104 self ,
87- size : int ,
105+ size : timedelta ,
88106 resampled_data_recv : Receiver [Sample ],
89- sampling_period : timedelta ,
90- window_alignment : datetime = datetime (1 , 1 , 1 ),
107+ input_sampling_period : timedelta ,
108+ resampler_config : ResamplerConfig | None = None ,
109+ window_alignment : datetime = datetime (1 , 1 , 1 , tzinfo = timezone .utc ),
91110 ) -> None :
92111 """
93112 Initialize the MovingWindow.
@@ -97,45 +116,97 @@ def __init__(
97116 The task stops running only if the channel receiver is closed.
98117
99118 Args:
100- size: The number of elements that are stored.
119+ size: The time span of the moving window over which samples will be stored.
101120 resampled_data_recv: A receiver that delivers samples with a
102121 given sampling period.
103- sampling_period: The sampling period.
122+ input_sampling_period: The time interval between consecutive input samples.
123+ resampler_config: The resampler configuration in case resampling is required.
104124 window_alignment: A datetime object that defines a point in time to which
105125 the window is aligned to modulo window size.
106- (default is midnight 01.01.01 )
126+ (default is 0001-01-01T00:00:00+00:00 )
107127 For further information, consult the class level documentation.
108128
109129 Raises:
110130 asyncio.CancelledError: when the task gets cancelled.
111131 """
132+ assert (
133+ input_sampling_period .total_seconds () > 0
134+ ), "The input sampling period should be greater than zero."
135+ assert (
136+ input_sampling_period <= size
137+ ), "The input sampling period should be equal to or lower than the window size."
138+
139+ sampling = input_sampling_period
140+ self ._resampler : Resampler | None = None
141+ self ._resampler_sender : Sender [Sample ] | None = None
142+ self ._resampler_task : asyncio .Task [None ] | None = None
143+
144+ if resampler_config :
145+ resampling_period = timedelta (seconds = resampler_config .resampling_period_s )
146+ assert (
147+ resampling_period <= size
148+ ), "The resampling period should be equal to or lower than the window size."
149+
150+ self ._resampler = Resampler (resampler_config )
151+ sampling = resampling_period
152+
153+ # Sampling period might not fit perfectly into the window size.
154+ num_samples = math .ceil (size .total_seconds () / sampling .total_seconds ())
155+
112156 self ._resampled_data_recv = resampled_data_recv
113157 self ._buffer = OrderedRingBuffer (
114- np .empty (shape = size , dtype = float ),
115- sampling_period = sampling_period ,
158+ np .empty (shape = num_samples , dtype = float ),
159+ sampling_period = sampling ,
116160 time_index_alignment = window_alignment ,
117161 )
118- self ._copy_buffer = False
162+
163+ if self ._resampler :
164+ self ._configure_resampler ()
165+
119166 self ._update_window_task : asyncio .Task [None ] = asyncio .create_task (
120167 self ._run_impl ()
121168 )
122- log .debug ("Cancelling MovingWindow task: %s" , __name__ )
123169
124170 async def _run_impl (self ) -> None :
125- """Awaits samples from the receiver and updates the underlying ringbuffer."""
171+ """Awaits samples from the receiver and updates the underlying ringbuffer.
172+
173+ Raises:
174+ asyncio.CancelledError: if the MovingWindow task is cancelled.
175+ """
126176 try :
127177 async for sample in self ._resampled_data_recv :
128178 log .debug ("Received new sample: %s" , sample )
129- self ._buffer .update (sample )
179+ if self ._resampler and self ._resampler_sender :
180+ await self ._resampler_sender .send (sample )
181+ else :
182+ self ._buffer .update (sample )
183+
130184 except asyncio .CancelledError :
131185 log .info ("MovingWindow task has been cancelled." )
132- return
186+ raise
133187
134188 log .error ("Channel has been closed" )
135189
136190 async def stop (self ) -> None :
137- """Cancel the running task and stop the MovingWindow."""
191+ """Cancel the running tasks and stop the MovingWindow."""
138192 await cancel_and_await (self ._update_window_task )
193+ if self ._resampler_task :
194+ await cancel_and_await (self ._resampler_task )
195+
196+ def _configure_resampler (self ) -> None :
197+ """Configure the components needed to run the resampler."""
198+ assert self ._resampler is not None
199+
200+ async def sink_buffer (sample : Sample ) -> None :
201+ if sample .value is not None :
202+ self ._buffer .update (sample )
203+
204+ resampler_channel = Broadcast [Sample ]("average" )
205+ self ._resampler_sender = resampler_channel .new_sender ()
206+ self ._resampler .add_timeseries (
207+ "avg" , resampler_channel .new_receiver (), sink_buffer
208+ )
209+ self ._resampler_task = asyncio .create_task (self ._resampler .resample ())
139210
140211 def __len__ (self ) -> int :
141212 """
@@ -198,7 +269,7 @@ def __getitem__(self, key: SupportsIndex | datetime | slice) -> float | ArrayLik
198269 # we are doing runtime typechecks since there is no abstract slice type yet
199270 # see also (https://peps.python.org/pep-0696)
200271 if isinstance (key .start , datetime ) and isinstance (key .stop , datetime ):
201- return self ._buffer .window (key .start , key .stop , self . _copy_buffer )
272+ return self ._buffer .window (key .start , key .stop )
202273 if isinstance (key .start , int ) and isinstance (key .stop , int ):
203274 return self ._buffer [key ]
204275 elif isinstance (key , datetime ):
0 commit comments