Skip to content

Commit d0e236e

Browse files
Change battery status when component stopped sending data
Set timer for battery and inverter messages. If no message was received for some time, then check timestamp and update battery status. Previously the timestamp was checked after received request_result. Because of that BatteryStatus could not work alone (without instance that sends request result). Signed-off-by: ela-kotulska-frequenz <[email protected]>
1 parent 82acdb9 commit d0e236e

File tree

2 files changed

+120
-35
lines changed

2 files changed

+120
-35
lines changed

src/frequenz/sdk/actor/power_distributing/_battery_status.py

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from frequenz.api.microgrid.common_pb2 import ErrorLevel
1717
from frequenz.api.microgrid.inverter_pb2 import ComponentState as InverterComponentState
1818
from frequenz.channels import Receiver, Sender
19-
from frequenz.channels.util import Select
19+
from frequenz.channels.util import Select, Timer
2020

2121
from frequenz.sdk._internal.asyncio import cancel_and_await
2222
from frequenz.sdk.microgrid import get as get_microgrid
@@ -62,8 +62,16 @@ class SetPowerResult:
6262
@dataclass
6363
class _ComponentStreamStatus:
6464
component_id: int
65+
"""Component id."""
66+
67+
data_recv_timer: Timer
68+
"""Timer that is set when no component data has been received for some time."""
69+
6570
last_msg_timestamp: datetime = datetime.now(tz=timezone.utc)
71+
"""Timestamp of the last message from the component."""
72+
6673
last_msg_correct: bool = False
74+
"""Flag whether last message was correct or not."""
6775

6876

6977
@dataclass
@@ -185,8 +193,12 @@ def __init__( # pylint: disable=too-many-arguments
185193
if inverter_id is None:
186194
raise RuntimeError(f"Can't find inverter adjacent to battery: {battery_id}")
187195

188-
self._battery = _ComponentStreamStatus(battery_id)
189-
self._inverter = _ComponentStreamStatus(inverter_id)
196+
self._battery = _ComponentStreamStatus(
197+
battery_id, data_recv_timer=Timer(max_data_age_sec)
198+
)
199+
self._inverter = _ComponentStreamStatus(
200+
inverter_id, data_recv_timer=Timer(max_data_age_sec)
201+
)
190202

191203
# Select needs receivers that can be get in async way only.
192204
self._select = None
@@ -232,6 +244,8 @@ async def _run(
232244

233245
self._select = Select(
234246
battery=battery_receiver,
247+
battery_timer=self._battery.data_recv_timer,
248+
inverter_timer=self._inverter.data_recv_timer,
235249
inverter=inverter_receiver,
236250
request_result=request_result_receiver,
237251
)
@@ -255,6 +269,7 @@ def _update_status(self, select: Select) -> Optional[Status]:
255269
and self._no_critical_error(msg.inner)
256270
)
257271
self._battery.last_msg_timestamp = msg.inner.timestamp
272+
self._battery.data_recv_timer.reset()
258273

259274
elif msg := select.inverter:
260275
self._inverter.last_msg_correct = (
@@ -263,37 +278,42 @@ def _update_status(self, select: Select) -> Optional[Status]:
263278
and self._no_critical_error(msg.inner)
264279
)
265280
self._inverter.last_msg_timestamp = msg.inner.timestamp
281+
self._inverter.data_recv_timer.reset()
266282

267283
elif msg := select.request_result:
268284
result: SetPowerResult = msg.inner
269285
if self.battery_id in result.succeed:
270286
self._blocking_status.unblock()
271287

272-
elif self.battery_id in result.failed:
273-
# check if component stopped sending data
274-
if self._battery.last_msg_correct and self._is_timestamp_outdated(
275-
self._battery.last_msg_timestamp
276-
):
277-
self._battery.last_msg_correct = False
278-
279-
if self._inverter.last_msg_correct and self._is_timestamp_outdated(
280-
self._inverter.last_msg_timestamp
281-
):
282-
self._inverter.last_msg_correct = False
283-
284-
component_correct = (
285-
self._battery.last_msg_correct and self._inverter.last_msg_correct
288+
elif (
289+
self.battery_id in result.failed
290+
and self._last_status != Status.NOT_WORKING
291+
):
292+
duration = self._blocking_status.block()
293+
294+
if duration > 0:
295+
_logger.warning(
296+
"battery %d failed last response. block it for %f sec",
297+
self.battery_id,
298+
duration,
299+
)
300+
elif msg := select.battery_timer:
301+
if self._battery.last_msg_correct:
302+
self._battery.last_msg_correct = False
303+
_logger.warning(
304+
"Battery %d stopped sending data, last timestamp: %s",
305+
self._battery.component_id,
306+
self._battery.last_msg_timestamp,
286307
)
287-
if component_correct:
288-
# if component is sending data, but request fails, then block it
289-
# for some time
290-
duration = self._blocking_status.block()
291-
if duration > 0:
292-
_logger.warning(
293-
"battery %d failed last response. block it for %f sec",
294-
self.battery_id,
295-
duration,
296-
)
308+
elif msg := select.inverter_timer:
309+
if self._inverter.last_msg_correct:
310+
self._inverter.last_msg_correct = False
311+
_logger.warning(
312+
"Inverter %d stopped sending data, last timestamp: %s",
313+
self._inverter.component_id,
314+
self._inverter.last_msg_timestamp,
315+
)
316+
297317
else:
298318
_logger.error("Unknown message returned from select")
299319

tests/actor/test_battery_status.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -190,21 +190,30 @@ class Message(Generic[T]):
190190
class FakeSelect:
191191
"""Helper class to mock Select object used in BatteryStatusTracker"""
192192

193-
def __init__(
193+
# This is just number of Select instance attributes.
194+
def __init__( # pylint: disable=too-many-arguments
194195
self,
195196
battery: Optional[BatteryData] = None,
196197
inverter: Optional[InverterData] = None,
197198
request_result: Optional[SetPowerResult] = None,
199+
battery_timer_flag: bool = False,
200+
inverter_timer_flag: bool = False,
198201
) -> None:
199202
"""Create FakeSelect instance
200203
201204
Args:
202205
battery: Expected battery message. Defaults to None.
203206
inverter: Expected inverter message. Defaults to None.
207+
battery_timer_flag: If true the battery data timer will be set to indicate
208+
that no messages have been received for a while.
209+
inverter_timer_flag: If true the inverter data timer will be set to indicate
210+
that no messages have been received for a while.
204211
request_result: Expected SetPowerResult message. Defaults to None.
205212
"""
206213
self.battery = None if battery is None else Message(battery)
207214
self.inverter = None if inverter is None else Message(inverter)
215+
self.inverter_timer = inverter_timer_flag
216+
self.battery_timer = battery_timer_flag
208217
self.request_result = (
209218
None if request_result is None else Message(request_result)
210219
)
@@ -536,13 +545,6 @@ async def test_sync_blocking_interrupted_with_with_max_data(
536545
assert tracker._update_status(select) is None # type: ignore[arg-type]
537546
time.shift(timeout)
538547

539-
# Battery message should be to old after 5 seconds.
540-
select = FakeSelect(
541-
request_result=SetPowerResult(succeed={1}, failed={106})
542-
)
543-
status = tracker._update_status(select) # type: ignore[arg-type]
544-
assert status is Status.NOT_WORKING
545-
546548
await tracker.stop()
547549

548550
@time_machine.travel("2022-01-01 00:00 UTC", tick=False)
@@ -597,6 +599,69 @@ async def test_sync_blocking_interrupted_with_invalid_message(
597599

598600
await tracker.stop()
599601

602+
@time_machine.travel("2022-01-01 00:00 UTC", tick=False)
603+
async def test_timers(
604+
self, mock_microgrid: MockMicrogridClient, mocker: MockerFixture
605+
) -> None:
606+
"""Test if messages changes battery status/
607+
608+
Tests uses FakeSelect to test status in sync way.
609+
Otherwise we would have lots of async calls and waiting.
610+
611+
Args:
612+
mock_microgrid: mock_microgrid fixture
613+
mocker: pytest mocker instance
614+
"""
615+
status_channel = Broadcast[Status]("battery_status")
616+
request_result_channel = Broadcast[SetPowerResult]("request_result")
617+
618+
tracker = BatteryStatusTracker(
619+
BATTERY_ID,
620+
max_data_age_sec=5,
621+
max_blocking_duration_sec=30,
622+
status_sender=status_channel.new_sender(),
623+
request_result_receiver=request_result_channel.new_receiver(),
624+
)
625+
626+
battery_timer_spy = mocker.spy(tracker._battery.data_recv_timer, "reset")
627+
inverter_timer_spy = mocker.spy(tracker._inverter.data_recv_timer, "reset")
628+
629+
assert tracker.battery_id == BATTERY_ID
630+
assert tracker._last_status == Status.NOT_WORKING
631+
632+
select = FakeSelect(inverter=inverter_data(component_id=INVERTER_ID))
633+
assert tracker._update_status(select) is None # type: ignore[arg-type]
634+
635+
select = FakeSelect(battery=battery_data(component_id=BATTERY_ID))
636+
assert tracker._update_status(select) is Status.WORKING # type: ignore[arg-type]
637+
638+
assert battery_timer_spy.call_count == 1
639+
640+
select = FakeSelect(battery_timer_flag=True)
641+
assert tracker._update_status(select) is Status.NOT_WORKING # type: ignore[arg-type]
642+
643+
assert battery_timer_spy.call_count == 1
644+
645+
select = FakeSelect(battery=battery_data(component_id=BATTERY_ID))
646+
assert tracker._update_status(select) is Status.WORKING # type: ignore[arg-type]
647+
648+
assert battery_timer_spy.call_count == 2
649+
650+
select = FakeSelect(inverter_timer_flag=True)
651+
assert tracker._update_status(select) is Status.NOT_WORKING # type: ignore[arg-type]
652+
653+
select = FakeSelect(battery_timer_flag=True)
654+
assert tracker._update_status(select) is None # type: ignore[arg-type]
655+
656+
select = FakeSelect(battery=battery_data(component_id=BATTERY_ID))
657+
assert tracker._update_status(select) is None # type: ignore[arg-type]
658+
659+
select = FakeSelect(inverter=inverter_data(component_id=INVERTER_ID))
660+
assert tracker._update_status(select) is Status.WORKING # type: ignore[arg-type]
661+
662+
assert inverter_timer_spy.call_count == 2
663+
await tracker.stop()
664+
600665
@time_machine.travel("2022-01-01 00:00 UTC", tick=False)
601666
async def test_async_battery_status(
602667
self, mock_microgrid: MockMicrogridClient

0 commit comments

Comments
 (0)