|
| 1 | +# License: MIT |
| 2 | +# Copyright © 2022 Frequenz Energy-as-a-Service GmbH |
| 3 | + |
| 4 | +"""Timeseries basic types.""" |
| 5 | + |
| 6 | +import dataclasses |
| 7 | +import functools |
| 8 | +from collections.abc import Callable, Iterator |
| 9 | +from dataclasses import dataclass |
| 10 | +from datetime import datetime, timezone |
| 11 | +from typing import Any, Generic, Protocol, Self, TypeVar, cast, overload |
| 12 | + |
| 13 | +from ._quantities import Power, QuantityT |
| 14 | + |
| 15 | +UNIX_EPOCH = datetime.fromtimestamp(0.0, tz=timezone.utc) |
| 16 | +"""The UNIX epoch (in UTC).""" |
| 17 | + |
| 18 | + |
| 19 | +@dataclass(frozen=True, order=True) |
| 20 | +class Sample(Generic[QuantityT]): |
| 21 | + """A measurement taken at a particular point in time. |
| 22 | +
|
| 23 | + The `value` could be `None` if a component is malfunctioning or data is |
| 24 | + lacking for another reason, but a sample still needs to be sent to have a |
| 25 | + coherent view on a group of component metrics for a particular timestamp. |
| 26 | + """ |
| 27 | + |
| 28 | + timestamp: datetime |
| 29 | + """The time when this sample was generated.""" |
| 30 | + |
| 31 | + value: QuantityT | None = None |
| 32 | + """The value of this sample.""" |
| 33 | + |
| 34 | + |
| 35 | +@dataclass(frozen=True) |
| 36 | +class Sample3Phase(Generic[QuantityT]): |
| 37 | + """A 3-phase measurement made at a particular point in time. |
| 38 | +
|
| 39 | + Each of the `value` fields could be `None` if a component is malfunctioning |
| 40 | + or data is lacking for another reason, but a sample still needs to be sent |
| 41 | + to have a coherent view on a group of component metrics for a particular |
| 42 | + timestamp. |
| 43 | + """ |
| 44 | + |
| 45 | + timestamp: datetime |
| 46 | + """The time when this sample was generated.""" |
| 47 | + value_p1: QuantityT | None |
| 48 | + """The value of the 1st phase in this sample.""" |
| 49 | + |
| 50 | + value_p2: QuantityT | None |
| 51 | + """The value of the 2nd phase in this sample.""" |
| 52 | + |
| 53 | + value_p3: QuantityT | None |
| 54 | + """The value of the 3rd phase in this sample.""" |
| 55 | + |
| 56 | + def __iter__(self) -> Iterator[QuantityT | None]: |
| 57 | + """Return an iterator that yields values from each of the phases. |
| 58 | +
|
| 59 | + Yields: |
| 60 | + Per-phase measurements one-by-one. |
| 61 | + """ |
| 62 | + yield self.value_p1 |
| 63 | + yield self.value_p2 |
| 64 | + yield self.value_p3 |
| 65 | + |
| 66 | + @overload |
| 67 | + def max(self, default: QuantityT) -> QuantityT: ... |
| 68 | + |
| 69 | + @overload |
| 70 | + def max(self, default: None = None) -> QuantityT | None: ... |
| 71 | + |
| 72 | + def max(self, default: QuantityT | None = None) -> QuantityT | None: |
| 73 | + """Return the max value among all phases, or default if they are all `None`. |
| 74 | +
|
| 75 | + Args: |
| 76 | + default: value to return if all phases are `None`. |
| 77 | +
|
| 78 | + Returns: |
| 79 | + Max value among all phases, if available, default value otherwise. |
| 80 | + """ |
| 81 | + if not any(self): |
| 82 | + return default |
| 83 | + value: QuantityT = functools.reduce( |
| 84 | + lambda x, y: x if x > y else y, |
| 85 | + filter(None, self), |
| 86 | + ) |
| 87 | + return value |
| 88 | + |
| 89 | + @overload |
| 90 | + def min(self, default: QuantityT) -> QuantityT: ... |
| 91 | + |
| 92 | + @overload |
| 93 | + def min(self, default: None = None) -> QuantityT | None: ... |
| 94 | + |
| 95 | + def min(self, default: QuantityT | None = None) -> QuantityT | None: |
| 96 | + """Return the min value among all phases, or default if they are all `None`. |
| 97 | +
|
| 98 | + Args: |
| 99 | + default: value to return if all phases are `None`. |
| 100 | +
|
| 101 | + Returns: |
| 102 | + Min value among all phases, if available, default value otherwise. |
| 103 | + """ |
| 104 | + if not any(self): |
| 105 | + return default |
| 106 | + value: QuantityT = functools.reduce( |
| 107 | + lambda x, y: x if x < y else y, |
| 108 | + filter(None, self), |
| 109 | + ) |
| 110 | + return value |
| 111 | + |
| 112 | + def map( |
| 113 | + self, |
| 114 | + function: Callable[[QuantityT], QuantityT], |
| 115 | + default: QuantityT | None = None, |
| 116 | + ) -> Self: |
| 117 | + """Apply the given function on each of the phase values and return the result. |
| 118 | +
|
| 119 | + If a phase value is `None`, replace it with `default` instead. |
| 120 | +
|
| 121 | + Args: |
| 122 | + function: The function to apply on each of the phase values. |
| 123 | + default: The value to apply if a phase value is `None`. |
| 124 | +
|
| 125 | + Returns: |
| 126 | + A new instance, with the given function applied on values for each of the |
| 127 | + phases. |
| 128 | + """ |
| 129 | + return self.__class__( |
| 130 | + timestamp=self.timestamp, |
| 131 | + value_p1=default if self.value_p1 is None else function(self.value_p1), |
| 132 | + value_p2=default if self.value_p2 is None else function(self.value_p2), |
| 133 | + value_p3=default if self.value_p3 is None else function(self.value_p3), |
| 134 | + ) |
| 135 | + |
| 136 | + |
| 137 | +class Comparable(Protocol): |
| 138 | + """A protocol that requires the implementation of comparison methods. |
| 139 | +
|
| 140 | + This protocol is used to ensure that types can be compared using |
| 141 | + the less than or equal to (`<=`) and greater than or equal to (`>=`) |
| 142 | + operators. |
| 143 | + """ |
| 144 | + |
| 145 | + def __le__(self, other: Any, /) -> bool: |
| 146 | + """Return whether this instance is less than or equal to `other`.""" |
| 147 | + |
| 148 | + def __ge__(self, other: Any, /) -> bool: |
| 149 | + """Return whether this instance is greater than or equal to `other`.""" |
| 150 | + |
| 151 | + |
| 152 | +_T = TypeVar("_T", bound=Comparable | None) |
| 153 | + |
| 154 | + |
| 155 | +@dataclass(frozen=True) |
| 156 | +class Bounds(Generic[_T]): |
| 157 | + """Lower and upper bound values.""" |
| 158 | + |
| 159 | + lower: _T |
| 160 | + """Lower bound.""" |
| 161 | + |
| 162 | + upper: _T |
| 163 | + """Upper bound.""" |
| 164 | + |
| 165 | + def __contains__(self, item: _T) -> bool: |
| 166 | + """ |
| 167 | + Check if the value is within the range of the container. |
| 168 | +
|
| 169 | + Args: |
| 170 | + item: The value to check. |
| 171 | +
|
| 172 | + Returns: |
| 173 | + bool: True if value is within the range, otherwise False. |
| 174 | + """ |
| 175 | + if self.lower is None and self.upper is None: |
| 176 | + return True |
| 177 | + if self.lower is None: |
| 178 | + return item <= self.upper |
| 179 | + if self.upper is None: |
| 180 | + return self.lower <= item |
| 181 | + |
| 182 | + return cast(Comparable, self.lower) <= item <= cast(Comparable, self.upper) |
| 183 | + |
| 184 | + |
| 185 | +@dataclass(frozen=True, kw_only=True) |
| 186 | +class SystemBounds: |
| 187 | + """Internal representation of system bounds for groups of components.""" |
| 188 | + |
| 189 | + # compare = False tells the dataclass to not use name for comparison methods |
| 190 | + timestamp: datetime = dataclasses.field(compare=False) |
| 191 | + """Timestamp of the metrics.""" |
| 192 | + |
| 193 | + inclusion_bounds: Bounds[Power] | None |
| 194 | + """Total inclusion power bounds for all components of a pool. |
| 195 | +
|
| 196 | + This is the range within which power requests would be allowed by the pool. |
| 197 | +
|
| 198 | + When exclusion bounds are present, they will exclude a subset of the inclusion |
| 199 | + bounds. |
| 200 | + """ |
| 201 | + |
| 202 | + exclusion_bounds: Bounds[Power] | None |
| 203 | + """Total exclusion power bounds for all components of a pool. |
| 204 | +
|
| 205 | + This is the range within which power requests are NOT allowed by the pool. |
| 206 | + If present, they will be a subset of the inclusion bounds. |
| 207 | + """ |
| 208 | + |
| 209 | + def __contains__(self, item: Power) -> bool: |
| 210 | + """ |
| 211 | + Check if the value is within the range of the container. |
| 212 | +
|
| 213 | + Args: |
| 214 | + item: The value to check. |
| 215 | +
|
| 216 | + Returns: |
| 217 | + bool: True if value is within the range, otherwise False. |
| 218 | + """ |
| 219 | + if not self.inclusion_bounds or item not in self.inclusion_bounds: |
| 220 | + return False |
| 221 | + if self.exclusion_bounds and item in self.exclusion_bounds: |
| 222 | + return False |
| 223 | + return True |
0 commit comments