Skip to content

Commit 0143fbd

Browse files
committed
Add an EVChargerStatusTracker implementation
Signed-off-by: Sahas Subramanian <[email protected]>
1 parent 7ef1420 commit 0143fbd

File tree

2 files changed

+187
-0
lines changed

2 files changed

+187
-0
lines changed

src/frequenz/sdk/actor/power_distributing/_component_status/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
ComponentStatusTracker,
1212
SetPowerResult,
1313
)
14+
from ._ev_charger_status_tracker import EVChargerStatusTracker
1415

1516
__all__ = [
1617
"BatteryStatusTracker",
1718
"ComponentPoolStatus",
1819
"ComponentStatus",
1920
"ComponentStatusEnum",
2021
"ComponentStatusTracker",
22+
"EVChargerStatusTracker",
2123
"SetPowerResult",
2224
]
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Background service that tracks the status of an EV charger."""
5+
6+
7+
import asyncio
8+
import logging
9+
from datetime import datetime, timedelta, timezone
10+
11+
from frequenz.channels import Receiver, Sender
12+
from frequenz.channels.util import SkipMissedAndDrift, Timer, select, selected_from
13+
from typing_extensions import override
14+
15+
from frequenz.sdk.microgrid import connection_manager
16+
from frequenz.sdk.microgrid.component import (
17+
EVChargerCableState,
18+
EVChargerComponentState,
19+
EVChargerData,
20+
)
21+
22+
from ..._background_service import BackgroundService
23+
from ._blocking_status import BlockingStatus
24+
from ._component_status import (
25+
ComponentStatus,
26+
ComponentStatusEnum,
27+
ComponentStatusTracker,
28+
SetPowerResult,
29+
)
30+
31+
_logger = logging.getLogger(__name__)
32+
33+
34+
class EVChargerStatusTracker(ComponentStatusTracker, BackgroundService):
35+
"""Status tracker for EV chargers."""
36+
37+
@override
38+
def __init__( # pylint: disable=too-many-arguments
39+
self,
40+
component_id: int,
41+
max_data_age: timedelta,
42+
max_blocking_duration: timedelta,
43+
status_sender: Sender[ComponentStatus],
44+
set_power_result_receiver: Receiver[SetPowerResult],
45+
) -> None:
46+
"""Create an `EVChargerStatusTracker` instance.
47+
48+
Args:
49+
component_id: ID of the EV charger to monitor the status of.
50+
max_data_age: max duration to wait for, before marking a component as
51+
NOT_WORKING, unless new data arrives.
52+
max_blocking_duration: duration for which the component status should be
53+
UNCERTAIN if a request to the component failed unexpectedly.
54+
status_sender: Channel sender to send status updates to.
55+
set_power_result_receiver: Receiver to fetch PowerDistributor responses
56+
from, to get the status of the most recent request made for an EV
57+
Charger.
58+
"""
59+
self._component_id = component_id
60+
self._max_data_age = max_data_age
61+
self._status_sender = status_sender
62+
self._set_power_result_receiver = set_power_result_receiver
63+
64+
self._last_status = ComponentStatusEnum.NOT_WORKING
65+
self._blocking_status = BlockingStatus(
66+
min_duration=timedelta(seconds=1.0),
67+
max_duration=max_blocking_duration,
68+
)
69+
70+
BackgroundService.__init__(self, name=f"EVChargerStatusTracker({component_id})")
71+
72+
@override
73+
def start(self) -> None:
74+
"""Start the status tracker."""
75+
self._tasks.add(asyncio.create_task(self._run()))
76+
77+
def _is_working(self, ev_data: EVChargerData) -> bool:
78+
"""Return whether the given data indicates that the component is working."""
79+
return (
80+
ev_data.cable_state == EVChargerCableState.EV_PLUGGED
81+
and ev_data.component_state
82+
in (
83+
EVChargerComponentState.READY,
84+
EVChargerComponentState.CHARGING,
85+
)
86+
)
87+
88+
def _is_stale(self, ev_data: EVChargerData) -> bool:
89+
"""Return whether the given data is stale."""
90+
now = datetime.now(tz=timezone.utc)
91+
stale = now - ev_data.timestamp > self._max_data_age
92+
return stale
93+
94+
async def _run_forever(self) -> None:
95+
"""Run the status tracker forever."""
96+
while True:
97+
try:
98+
await self._run()
99+
except Exception as ex: # pylint: disable=broad-except
100+
_logger.exception(
101+
"Restarting after exception in EVChargerStatusTracker: %s", ex
102+
)
103+
await asyncio.sleep(1.0)
104+
105+
def _handle_ev_data(self, ev_data: EVChargerData) -> ComponentStatusEnum:
106+
"""Handle new EV charger data."""
107+
if self._is_stale(ev_data):
108+
if self._last_status == ComponentStatusEnum.WORKING:
109+
_logger.warning(
110+
"EV charger %s data is stale. Last timestamp: %s",
111+
self._component_id,
112+
ev_data.timestamp,
113+
)
114+
return ComponentStatusEnum.NOT_WORKING
115+
116+
if self._is_working(ev_data):
117+
if self._last_status == ComponentStatusEnum.NOT_WORKING:
118+
_logger.warning(
119+
"EV charger %s is in WORKING state.",
120+
self._component_id,
121+
)
122+
return ComponentStatusEnum.WORKING
123+
124+
if self._last_status == ComponentStatusEnum.WORKING:
125+
_logger.warning(
126+
"EV charger %s is in NOT_WORKING state. "
127+
"Cable state: %s, component state: %s",
128+
self._component_id,
129+
ev_data.cable_state,
130+
ev_data.component_state,
131+
)
132+
return ComponentStatusEnum.NOT_WORKING
133+
134+
def _handle_set_power_result(
135+
self, set_power_result: SetPowerResult
136+
) -> ComponentStatusEnum:
137+
"""Handle a new set power result."""
138+
if self._component_id in set_power_result.succeeded:
139+
return ComponentStatusEnum.WORKING
140+
141+
self._blocking_status.block()
142+
if self._last_status == ComponentStatusEnum.WORKING:
143+
_logger.warning(
144+
"EV charger %s is in UNCERTAIN state. Set power result: %s",
145+
self._component_id,
146+
set_power_result,
147+
)
148+
return ComponentStatusEnum.UNCERTAIN
149+
150+
async def _run(self) -> None:
151+
"""Run the status tracker."""
152+
api_client = connection_manager.get().api_client
153+
ev_data_rx = await api_client.ev_charger_data(self._component_id)
154+
set_power_result_rx = self._set_power_result_receiver
155+
missing_data_timer = Timer(self._max_data_age, SkipMissedAndDrift())
156+
async for selected in select(
157+
ev_data_rx, set_power_result_rx, missing_data_timer
158+
):
159+
new_status = ComponentStatusEnum.NOT_WORKING
160+
if selected_from(selected, ev_data_rx):
161+
new_status = self._handle_ev_data(selected.value)
162+
elif selected_from(selected, set_power_result_rx):
163+
new_status = self._handle_set_power_result(selected.value)
164+
elif selected_from(selected, missing_data_timer):
165+
_logger.warning(
166+
"No EV charger %s data received for %s. Setting status to NOT_WORKING.",
167+
self._component_id,
168+
self._max_data_age,
169+
)
170+
171+
# Send status update if status changed
172+
if self._blocking_status.is_blocked():
173+
new_status = ComponentStatusEnum.UNCERTAIN
174+
175+
if new_status != self._last_status:
176+
_logger.info(
177+
"EV charger %s status changed from %s to %s",
178+
self._component_id,
179+
self._last_status,
180+
new_status,
181+
)
182+
self._last_status = new_status
183+
await self._status_sender.send(
184+
ComponentStatus(self._component_id, new_status)
185+
)

0 commit comments

Comments
 (0)