Skip to content

Commit 35c6e30

Browse files
Add battery status module to track status of the battery
This should be used to check if data from the battery should be used in computing and if battery should be used to set power. Signed-off-by: ela-kotulska-frequenz <[email protected]>
1 parent b7cabac commit 35c6e30

File tree

2 files changed

+386
-0
lines changed

2 files changed

+386
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# License: MIT
2+
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Microgrid battery utils module.
5+
6+
Stores features for the batteries.
7+
"""
8+
9+
from ._status import BatteryStatus, StatusTracker
10+
11+
__all__ = [
12+
"StatusTracker",
13+
"BatteryStatus",
14+
]
Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
# License: MIT
2+
# Copyright © 2022 Frequenz Energy-as-a-Service GmbH
3+
"""Class to return battery status."""
4+
5+
import asyncio
6+
import logging
7+
from dataclasses import dataclass
8+
from datetime import datetime, timedelta, timezone
9+
from enum import Enum
10+
from typing import Generic, Optional, Set, TypeVar, Union
11+
12+
from frequenz.api.microgrid.battery_pb2 import ComponentState as BatteryComponentState
13+
from frequenz.api.microgrid.battery_pb2 import RelayState as BatteryRelayState
14+
from frequenz.api.microgrid.common_pb2 import ErrorLevel
15+
from frequenz.api.microgrid.inverter_pb2 import ComponentState as InverterComponentState
16+
from frequenz.channels import Peekable
17+
18+
from .. import ComponentGraph
19+
from .. import get as get_microgrid
20+
from ..component import BatteryData, ComponentCategory, ComponentData, InverterData
21+
22+
# Time needed to get first component data.
23+
# Constant for now. In future should be configurable in UI.
24+
_START_DELAY_SEC = 2.0
25+
26+
_logger = logging.getLogger(__name__)
27+
28+
T = TypeVar("T")
29+
30+
31+
class BatteryStatus(Enum):
32+
"""Tells if battery is can be used."""
33+
34+
# Component it is not working.
35+
NOT_WORKING = 0
36+
# Component should work, but it failed in last request. It is blocked for few
37+
# seconds and it is not recommended to use it until it is necessary.
38+
UNCERTAIN = 1
39+
# Component is working
40+
WORKING = 2
41+
42+
43+
@dataclass
44+
class _ComponentReceiver(Generic[T]):
45+
component_id: int
46+
receiver: Peekable[T]
47+
48+
49+
class StatusTracker:
50+
"""Class for tracking if battery is working."""
51+
52+
_battery_valid_relay: Set[BatteryRelayState.ValueType] = {
53+
BatteryRelayState.RELAY_STATE_CLOSED
54+
}
55+
_battery_valid_state: Set[BatteryComponentState.ValueType] = {
56+
BatteryComponentState.COMPONENT_STATE_IDLE,
57+
BatteryComponentState.COMPONENT_STATE_CHARGING,
58+
BatteryComponentState.COMPONENT_STATE_DISCHARGING,
59+
}
60+
_inverter_valid_state: Set[InverterComponentState.ValueType] = {
61+
InverterComponentState.COMPONENT_STATE_STANDBY,
62+
InverterComponentState.COMPONENT_STATE_IDLE,
63+
InverterComponentState.COMPONENT_STATE_CHARGING,
64+
InverterComponentState.COMPONENT_STATE_STANDBY,
65+
}
66+
67+
def __init__(
68+
self, battery_id: int, max_data_age_sec: float, max_blocking_duration_sec: float
69+
) -> None:
70+
"""Create class instance.
71+
72+
Args:
73+
battery_id: Id of this battery
74+
max_data_age_sec: If component stopped sending data, then
75+
this is the maximum time when its last message should be considered as
76+
valid. After that time, component won't be used until it starts sending
77+
data.
78+
max_blocking_duration_sec: This value tell what should be the maximum
79+
timeout used for blocking failing component.
80+
"""
81+
self._battery_id: int = battery_id
82+
self._max_data_age: float = max_data_age_sec
83+
84+
self._init_method_called: bool = False
85+
self._last_status: BatteryStatus = BatteryStatus.WORKING
86+
87+
self._min_blocking_duration_sec: float = 1.0
88+
self._max_blocking_duration_sec: float = max_blocking_duration_sec
89+
self._blocked_until: Optional[datetime] = None
90+
self._last_blocking_duration_sec: float = self._min_blocking_duration_sec
91+
92+
@property
93+
def battery_id(self) -> int:
94+
"""Get battery id.
95+
96+
Returns:
97+
Battery id
98+
"""
99+
return self._battery_id
100+
101+
async def async_init(self) -> None:
102+
"""Async part of constructor.
103+
104+
This should be called in order to subscribe for necessary data.
105+
106+
Raises:
107+
RuntimeError: If given battery as no adjacent inverter.
108+
"""
109+
component_graph = get_microgrid().component_graph
110+
inverter_id = self._find_adjacent_inverter_id(component_graph)
111+
if inverter_id is None:
112+
raise RuntimeError(
113+
f"Can't find inverter adjacent to battery: {self._battery_id}"
114+
)
115+
116+
api_client = get_microgrid().api_client
117+
118+
bat_recv = await api_client.battery_data(self._battery_id)
119+
# Defining battery_receiver and inverter receiver needs await.
120+
# Because of that it is impossible to define it in constructor.
121+
# Setting it to None first would require to check None condition in
122+
# every call.
123+
# pylint: disable=attribute-defined-outside-init
124+
self._battery_receiver = _ComponentReceiver(
125+
self._battery_id, bat_recv.into_peekable()
126+
)
127+
inv_recv = await api_client.inverter_data(inverter_id)
128+
# pylint: disable=attribute-defined-outside-init
129+
self._inverter_receiver = _ComponentReceiver(
130+
inverter_id, inv_recv.into_peekable()
131+
)
132+
133+
await asyncio.sleep(_START_DELAY_SEC)
134+
self._init_method_called = True
135+
136+
def get_status(self) -> BatteryStatus:
137+
"""Return status of the battery.
138+
139+
The decision is made based on last message from battery and adjacent inverter
140+
and result from last request.
141+
142+
Raises:
143+
RuntimeError: If method `async_init` was not called or not awaited.
144+
145+
Returns:
146+
Battery status
147+
"""
148+
if not self._init_method_called:
149+
raise RuntimeError(
150+
"`async_init` method not called or not awaited. Run it before first use"
151+
)
152+
153+
bat_msg = self._battery_receiver.receiver.peek()
154+
inv_msg = self._inverter_receiver.receiver.peek()
155+
if bat_msg is None or inv_msg is None:
156+
if self._last_status == BatteryStatus.WORKING:
157+
if not bat_msg:
158+
component_id = self._battery_id
159+
else:
160+
component_id = self._inverter_receiver.component_id
161+
162+
_logger.warning(
163+
"None returned from component %d receiver.", component_id
164+
)
165+
self._last_status = BatteryStatus.NOT_WORKING
166+
return self._last_status
167+
168+
older_message = bat_msg if bat_msg.timestamp < inv_msg.timestamp else inv_msg
169+
is_msg_ok = (
170+
self._is_message_reliable(older_message)
171+
and self._is_battery_state_correct(bat_msg)
172+
and self._is_inverter_state_correct(inv_msg)
173+
and self._no_critical_error(bat_msg)
174+
and self._no_critical_error(inv_msg)
175+
)
176+
177+
if not is_msg_ok:
178+
self._last_status = BatteryStatus.NOT_WORKING
179+
return self._last_status
180+
181+
# Use battery as soon as its message is back correct.
182+
if self._last_status == BatteryStatus.NOT_WORKING:
183+
self.unblock()
184+
185+
if self.is_blocked():
186+
self._last_status = BatteryStatus.UNCERTAIN
187+
else:
188+
self._last_status = BatteryStatus.WORKING
189+
190+
return self._last_status
191+
192+
def is_blocked(self) -> bool:
193+
"""Return if battery is blocked.
194+
195+
Battery can be blocked if last request for that battery failed.
196+
197+
Returns:
198+
True if battery is blocked, False otherwise.
199+
"""
200+
if self._blocked_until is None:
201+
return False
202+
return self._blocked_until > datetime.now(tz=timezone.utc)
203+
204+
def unblock(self) -> None:
205+
"""Unblock battery.
206+
207+
This will reset duration of the next blocking timeout.
208+
209+
Battery can be blocked using `self.block()` method.
210+
"""
211+
self._blocked_until = None
212+
213+
def block(self) -> float:
214+
"""Block battery.
215+
216+
Battery can be unblocked using `self.unblock()` method.
217+
218+
Returns:
219+
For how long (in seconds) the battery is blocked.
220+
"""
221+
now = datetime.now(tz=timezone.utc)
222+
223+
if self._blocked_until is None:
224+
self._last_blocking_duration_sec = self._min_blocking_duration_sec
225+
self._blocked_until = now + timedelta(
226+
seconds=self._last_blocking_duration_sec
227+
)
228+
return self._last_blocking_duration_sec
229+
230+
# If still blocked, then do nothing
231+
if self._blocked_until > now:
232+
return 0.0
233+
234+
# Increase blocking duration twice or until it reach the max.
235+
self._last_blocking_duration_sec = min(
236+
2 * self._last_blocking_duration_sec, self._max_blocking_duration_sec
237+
)
238+
self._blocked_until = now + timedelta(seconds=self._last_blocking_duration_sec)
239+
240+
return self._last_blocking_duration_sec
241+
242+
@property
243+
def blocked_until(self) -> Optional[datetime]:
244+
"""Return timestamp when the battery will be unblocked.
245+
246+
Returns:
247+
Timestamp when the battery will be unblocked. Return None if battery is
248+
not blocked.
249+
"""
250+
if self._blocked_until is None or self._blocked_until < datetime.now(
251+
tz=timezone.utc
252+
):
253+
return None
254+
return self._blocked_until
255+
256+
def _no_critical_error(self, msg: Union[BatteryData, InverterData]) -> bool:
257+
"""Check if battery or inverter message has any critical error.
258+
259+
Args:
260+
msg: message.
261+
262+
Returns:
263+
True if message has no critical error, False otherwise.
264+
"""
265+
critical = ErrorLevel.ERROR_LEVEL_CRITICAL
266+
# pylint: disable=protected-access
267+
critical_err = next((err for err in msg._errors if err.level == critical), None)
268+
if critical_err is not None:
269+
if self._last_status == BatteryStatus.WORKING:
270+
_logger.warning(
271+
"Component %d has critical error: %s",
272+
msg.component_id,
273+
str(critical_err),
274+
)
275+
return False
276+
return True
277+
278+
def _is_inverter_state_correct(self, msg: InverterData) -> bool:
279+
"""Check if inverter is in correct state from message.
280+
281+
Args:
282+
msg: message
283+
284+
Returns:
285+
True if inverter is in correct state. False otherwise.
286+
"""
287+
# Component state is not exposed to the user.
288+
# pylint: disable=protected-access
289+
state = msg._component_state
290+
if state not in StatusTracker._inverter_valid_state:
291+
if self._last_status == BatteryStatus.WORKING:
292+
_logger.warning(
293+
"Inverter %d has invalid state: %s",
294+
msg.component_id,
295+
str(state),
296+
)
297+
return False
298+
return True
299+
300+
def _is_battery_state_correct(self, msg: BatteryData) -> bool:
301+
"""Check if battery is in correct state from message.
302+
303+
Args:
304+
msg: message
305+
306+
Returns:
307+
True if battery is in correct state. False otherwise.
308+
"""
309+
# Component state is not exposed to the user.
310+
# pylint: disable=protected-access
311+
state = msg._component_state
312+
if state not in StatusTracker._battery_valid_state:
313+
if self._last_status == BatteryStatus.WORKING:
314+
_logger.warning(
315+
"Battery %d has invalid state: %s",
316+
self._battery_id,
317+
str(state),
318+
)
319+
return False
320+
321+
# Component state is not exposed to the user.
322+
# pylint: disable=protected-access
323+
relay_state = msg._relay_state
324+
if relay_state not in StatusTracker._battery_valid_relay:
325+
if self._last_status == BatteryStatus.WORKING:
326+
_logger.warning(
327+
"Battery %d has invalid relay state: %s",
328+
self._battery_id,
329+
str(relay_state),
330+
)
331+
return False
332+
return True
333+
334+
def _is_message_reliable(self, message: ComponentData) -> bool:
335+
"""Check if message is too old to be considered as reliable.
336+
337+
Args:
338+
message: message to check
339+
340+
Returns:
341+
True if message is reliable, False otherwise.
342+
"""
343+
now = datetime.now(tz=timezone.utc)
344+
diff = (now - message.timestamp).total_seconds()
345+
is_outdated = diff > self._max_data_age
346+
347+
if is_outdated and self._last_status == BatteryStatus.WORKING:
348+
_logger.warning(
349+
"Component %d stopped sending data. Last timestamp: %s.",
350+
message.component_id,
351+
str(message.timestamp),
352+
)
353+
354+
return not is_outdated
355+
356+
def _find_adjacent_inverter_id(self, graph: ComponentGraph) -> Optional[int]:
357+
"""Find inverter adjacent to this battery.
358+
359+
Args:
360+
graph: Component graph
361+
362+
Returns:
363+
Id of the inverter. If battery hasn't adjacent inverter, then return None.
364+
"""
365+
return next(
366+
(
367+
comp.component_id
368+
for comp in graph.predecessors(self._battery_id)
369+
if comp.category == ComponentCategory.INVERTER
370+
),
371+
None,
372+
)

0 commit comments

Comments
 (0)