|
8 | 8 | import logging
|
9 | 9 | import statistics
|
10 | 10 | from collections.abc import Sequence
|
11 |
| -from dataclasses import dataclass |
| 11 | +from dataclasses import dataclass, field |
12 | 12 | from datetime import datetime, timedelta
|
13 | 13 | from typing import Protocol
|
14 | 14 |
|
15 | 15 | from frequenz.core.datetime import UNIX_EPOCH
|
16 | 16 |
|
17 | 17 | from ._base_types import SourceProperties
|
| 18 | +from ._wall_clock_timer import WallClockTimerConfig |
18 | 19 |
|
19 | 20 | _logger = logging.getLogger(__name__)
|
20 | 21 |
|
@@ -210,3 +211,173 @@ def __post_init__(self) -> None:
|
210 | 211 | raise ValueError(
|
211 | 212 | f"align_to ({self.align_to}) should be a timezone aware datetime"
|
212 | 213 | )
|
| 214 | + |
| 215 | + |
| 216 | +class ResamplingFunction2(Protocol): |
| 217 | + """Combine multiple samples into a new one. |
| 218 | +
|
| 219 | + A resampling function produces a new sample based on a list of pre-existing |
| 220 | + samples. It can do "upsampling" when the data rate of the `input_samples` |
| 221 | + period is smaller than the `resampling_period`, or "downsampling" if it is |
| 222 | + bigger. |
| 223 | +
|
| 224 | + In general, a resampling window is the same as the `resampling_period`, and |
| 225 | + this function might receive input samples from multiple windows in the past to |
| 226 | + enable extrapolation, but no samples from the future (so the timestamp of the |
| 227 | + new sample that is going to be produced will always be bigger than the biggest |
| 228 | + timestamp in the input data). |
| 229 | + """ |
| 230 | + |
| 231 | + def __call__( |
| 232 | + self, |
| 233 | + input_samples: Sequence[tuple[datetime, float]], |
| 234 | + resampler_config: ResamplerConfig | ResamplerConfig2, |
| 235 | + source_properties: SourceProperties, |
| 236 | + /, |
| 237 | + ) -> float: |
| 238 | + """Call the resampling function. |
| 239 | +
|
| 240 | + Args: |
| 241 | + input_samples: The sequence of pre-existing samples, where the first item is |
| 242 | + the timestamp of the sample, and the second is the value of the sample. |
| 243 | + The sequence must be non-empty. |
| 244 | + resampler_config: The configuration of the resampler calling this |
| 245 | + function. |
| 246 | + source_properties: The properties of the source being resampled. |
| 247 | +
|
| 248 | + Returns: |
| 249 | + The value of new sample produced after the resampling. |
| 250 | + """ |
| 251 | + ... # pylint: disable=unnecessary-ellipsis |
| 252 | + |
| 253 | + |
| 254 | +@dataclass(frozen=True) |
| 255 | +class ResamplerConfig2(ResamplerConfig): |
| 256 | + """Resampler configuration.""" |
| 257 | + |
| 258 | + resampling_period: timedelta |
| 259 | + """The resampling period. |
| 260 | +
|
| 261 | + This is the time it passes between resampled data should be calculated. |
| 262 | +
|
| 263 | + It must be a positive time span. |
| 264 | + """ |
| 265 | + |
| 266 | + max_data_age_in_periods: float = 3.0 |
| 267 | + """The maximum age a sample can have to be considered *relevant* for resampling. |
| 268 | +
|
| 269 | + Expressed in number of periods, where period is the `resampling_period` |
| 270 | + if we are downsampling (resampling period bigger than the input period) or |
| 271 | + the *input sampling period* if we are upsampling (input period bigger than |
| 272 | + the resampling period). |
| 273 | +
|
| 274 | + It must be bigger than 1.0. |
| 275 | +
|
| 276 | + Example: |
| 277 | + If `resampling_period` is 3 seconds, the input sampling period is |
| 278 | + 1 and `max_data_age_in_periods` is 2, then data older than 3*2 |
| 279 | + = 6 seconds will be discarded when creating a new sample and never |
| 280 | + passed to the resampling function. |
| 281 | +
|
| 282 | + If `resampling_period` is 3 seconds, the input sampling period is |
| 283 | + 5 and `max_data_age_in_periods` is 2, then data older than 5*2 |
| 284 | + = 10 seconds will be discarded when creating a new sample and never |
| 285 | + passed to the resampling function. |
| 286 | + """ |
| 287 | + |
| 288 | + resampling_function: ResamplingFunction2 = lambda samples, _, __: statistics.fmean( |
| 289 | + s[1] for s in samples |
| 290 | + ) |
| 291 | + """The resampling function. |
| 292 | +
|
| 293 | + This function will be applied to the sequence of relevant samples at |
| 294 | + a given time. The result of the function is what is sent as the resampled |
| 295 | + value. |
| 296 | + """ |
| 297 | + |
| 298 | + initial_buffer_len: int = DEFAULT_BUFFER_LEN_INIT |
| 299 | + """The initial length of the resampling buffer. |
| 300 | +
|
| 301 | + The buffer could grow or shrink depending on the source properties, |
| 302 | + like sampling rate, to make sure all the requested past sampling periods |
| 303 | + can be stored. |
| 304 | +
|
| 305 | + It must be at least 1 and at most `max_buffer_len`. |
| 306 | + """ |
| 307 | + |
| 308 | + warn_buffer_len: int = DEFAULT_BUFFER_LEN_WARN |
| 309 | + """The minimum length of the resampling buffer that will emit a warning. |
| 310 | +
|
| 311 | + If a buffer grows bigger than this value, it will emit a warning in the |
| 312 | + logs, so buffers don't grow too big inadvertently. |
| 313 | +
|
| 314 | + It must be at least 1 and at most `max_buffer_len`. |
| 315 | + """ |
| 316 | + |
| 317 | + max_buffer_len: int = DEFAULT_BUFFER_LEN_MAX |
| 318 | + """The maximum length of the resampling buffer. |
| 319 | +
|
| 320 | + Buffers won't be allowed to grow beyond this point even if it would be |
| 321 | + needed to keep all the requested past sampling periods. An error will be |
| 322 | + emitted in the logs if the buffer length needs to be truncated to this |
| 323 | + value. |
| 324 | +
|
| 325 | + It must be at bigger than `warn_buffer_len`. |
| 326 | + """ |
| 327 | + |
| 328 | + align_to: datetime | None = field(default=None, init=False) |
| 329 | + """Deprecated: Use timer_config.align_to instead.""" |
| 330 | + |
| 331 | + timer_config: WallClockTimerConfig | None = None |
| 332 | + """The custom configuration of the wall clock timer used to keep track of time. |
| 333 | +
|
| 334 | + If not provided or `None`, a configuration will be created by passing the |
| 335 | + [`resampling_period`][frequenz.sdk.timeseries.ResamplerConfig2.resampling_period] to |
| 336 | + the [`from_interval()`][frequenz.sdk.timeseries.WallClockTimerConfig.from_interval] |
| 337 | + method. |
| 338 | + """ |
| 339 | + |
| 340 | + def __post_init__(self) -> None: |
| 341 | + """Check that config values are valid. |
| 342 | +
|
| 343 | + Raises: |
| 344 | + ValueError: If any value is out of range. |
| 345 | + """ |
| 346 | + if self.resampling_period.total_seconds() < 0.0: |
| 347 | + raise ValueError( |
| 348 | + f"resampling_period ({self.resampling_period}) must be positive" |
| 349 | + ) |
| 350 | + if self.max_data_age_in_periods < 1.0: |
| 351 | + raise ValueError( |
| 352 | + f"max_data_age_in_periods ({self.max_data_age_in_periods}) should be at least 1.0" |
| 353 | + ) |
| 354 | + if self.warn_buffer_len < 1: |
| 355 | + raise ValueError( |
| 356 | + f"warn_buffer_len ({self.warn_buffer_len}) should be at least 1" |
| 357 | + ) |
| 358 | + if self.max_buffer_len <= self.warn_buffer_len: |
| 359 | + raise ValueError( |
| 360 | + f"max_buffer_len ({self.max_buffer_len}) should " |
| 361 | + f"be bigger than warn_buffer_len ({self.warn_buffer_len})" |
| 362 | + ) |
| 363 | + |
| 364 | + if self.initial_buffer_len < 1: |
| 365 | + raise ValueError( |
| 366 | + f"initial_buffer_len ({self.initial_buffer_len}) should at least 1" |
| 367 | + ) |
| 368 | + if self.initial_buffer_len > self.max_buffer_len: |
| 369 | + raise ValueError( |
| 370 | + f"initial_buffer_len ({self.initial_buffer_len}) is bigger " |
| 371 | + f"than max_buffer_len ({self.max_buffer_len}), use a smaller " |
| 372 | + "initial_buffer_len or a bigger max_buffer_len" |
| 373 | + ) |
| 374 | + if self.initial_buffer_len > self.warn_buffer_len: |
| 375 | + _logger.warning( |
| 376 | + "initial_buffer_len (%s) is bigger than warn_buffer_len (%s)", |
| 377 | + self.initial_buffer_len, |
| 378 | + self.warn_buffer_len, |
| 379 | + ) |
| 380 | + if self.align_to is not None: |
| 381 | + raise ValueError( |
| 382 | + f"align_to ({self.align_to}) must be specified via timer_config" |
| 383 | + ) |
0 commit comments