Skip to content

Commit fe79d5f

Browse files
committed
Copy quantities and base types from SDK v1.0.0-rc901
1 parent 93c2eb5 commit fe79d5f

File tree

2 files changed

+1564
-0
lines changed

2 files changed

+1564
-0
lines changed
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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

Comments
 (0)