Skip to content

Commit b1249e1

Browse files
committed
Add an EVChargerStatusTracker implementation
It reports an EV charger as `WORKING` when an EV is connected to it, and power can be allocated to it, and `NOT_WORKING` otherwise. If it receives a power assignment failure from the PowerDistributor, when the component is expected to be working, it is marked as `UNCERTAIN` for a specific interval, before being marked `WORKING` again. This status would be used in the PowerDistributor and in the EVChargerPool. Signed-off-by: Sahas Subramanian <[email protected]>
1 parent f1fa336 commit b1249e1

File tree

3 files changed

+371
-0
lines changed

3 files changed

+371
-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: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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, select, selected_from
12+
from frequenz.channels.timer import SkipMissedAndDrift, Timer
13+
from frequenz.client.microgrid import (
14+
EVChargerCableState,
15+
EVChargerComponentState,
16+
EVChargerData,
17+
)
18+
from typing_extensions import override
19+
20+
from frequenz.sdk.microgrid import connection_manager
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+
It reports an EV charger as `WORKING` when an EV is connected to it,
38+
and power can be allocated to it, and `NOT_WORKING` otherwise.
39+
40+
If it receives a power assignment failure from the PowerDistributor,
41+
when the component is expected to be `WORKING`, it is marked as
42+
`UNCERTAIN` for a specific interval, before being marked `WORKING`
43+
again.
44+
"""
45+
46+
@override
47+
def __init__( # pylint: disable=too-many-arguments
48+
self,
49+
component_id: int,
50+
max_data_age: timedelta,
51+
max_blocking_duration: timedelta,
52+
status_sender: Sender[ComponentStatus],
53+
set_power_result_receiver: Receiver[SetPowerResult],
54+
) -> None:
55+
"""Create an `EVChargerStatusTracker` instance.
56+
57+
Args:
58+
component_id: ID of the EV charger to monitor the status of.
59+
max_data_age: max duration to wait for, before marking a component as
60+
NOT_WORKING, unless new data arrives.
61+
max_blocking_duration: duration for which the component status should be
62+
UNCERTAIN if a request to the component failed unexpectedly.
63+
status_sender: Channel sender to send status updates to.
64+
set_power_result_receiver: Receiver to fetch PowerDistributor responses
65+
from, to get the status of the most recent request made for an EV
66+
Charger.
67+
"""
68+
self._component_id = component_id
69+
self._max_data_age = max_data_age
70+
self._status_sender = status_sender
71+
self._set_power_result_receiver = set_power_result_receiver
72+
73+
self._last_status = ComponentStatusEnum.NOT_WORKING
74+
self._blocking_status = BlockingStatus(
75+
min_duration=timedelta(seconds=1.0),
76+
max_duration=max_blocking_duration,
77+
)
78+
79+
BackgroundService.__init__(self, name=f"EVChargerStatusTracker({component_id})")
80+
81+
@override
82+
def start(self) -> None:
83+
"""Start the status tracker."""
84+
self._tasks.add(asyncio.create_task(self._run_forever()))
85+
86+
def _is_working(self, ev_data: EVChargerData) -> bool:
87+
"""Return whether the given data indicates that the component is working."""
88+
return ev_data.cable_state in (
89+
EVChargerCableState.EV_PLUGGED,
90+
EVChargerCableState.EV_LOCKED,
91+
) and ev_data.component_state in (
92+
EVChargerComponentState.READY,
93+
EVChargerComponentState.CHARGING,
94+
EVChargerComponentState.DISCHARGING,
95+
)
96+
97+
def _is_stale(self, ev_data: EVChargerData) -> bool:
98+
"""Return whether the given data is stale."""
99+
now = datetime.now(tz=timezone.utc)
100+
stale = now - ev_data.timestamp > self._max_data_age
101+
return stale
102+
103+
async def _run_forever(self) -> None:
104+
"""Run the status tracker forever."""
105+
while True:
106+
try:
107+
await self._run()
108+
except Exception: # pylint: disable=broad-except
109+
_logger.exception(
110+
"Restarting after exception in EVChargerStatusTracker.run()"
111+
)
112+
await asyncio.sleep(1.0)
113+
114+
def _handle_ev_data(self, ev_data: EVChargerData) -> ComponentStatusEnum:
115+
"""Handle new EV charger data."""
116+
if self._is_stale(ev_data):
117+
if self._last_status == ComponentStatusEnum.WORKING:
118+
_logger.warning(
119+
"EV charger %s data is stale. Last timestamp: %s",
120+
self._component_id,
121+
ev_data.timestamp,
122+
)
123+
return ComponentStatusEnum.NOT_WORKING
124+
125+
if self._is_working(ev_data):
126+
if self._last_status == ComponentStatusEnum.NOT_WORKING:
127+
_logger.warning(
128+
"EV charger %s is in WORKING state.",
129+
self._component_id,
130+
)
131+
return ComponentStatusEnum.WORKING
132+
133+
if self._last_status == ComponentStatusEnum.WORKING:
134+
_logger.warning(
135+
"EV charger %s is in NOT_WORKING state. "
136+
"Cable state: %s, component state: %s",
137+
self._component_id,
138+
ev_data.cable_state,
139+
ev_data.component_state,
140+
)
141+
return ComponentStatusEnum.NOT_WORKING
142+
143+
def _handle_set_power_result(
144+
self, set_power_result: SetPowerResult
145+
) -> ComponentStatusEnum:
146+
"""Handle a new set power result."""
147+
if self._component_id in set_power_result.succeeded:
148+
return ComponentStatusEnum.WORKING
149+
150+
self._blocking_status.block()
151+
if self._last_status == ComponentStatusEnum.WORKING:
152+
_logger.warning(
153+
"EV charger %s is in UNCERTAIN state. Set power result: %s",
154+
self._component_id,
155+
set_power_result,
156+
)
157+
return ComponentStatusEnum.UNCERTAIN
158+
159+
async def _run(self) -> None:
160+
"""Run the status tracker."""
161+
api_client = connection_manager.get().api_client
162+
ev_data_rx = await api_client.ev_charger_data(self._component_id)
163+
set_power_result_rx = self._set_power_result_receiver
164+
missing_data_timer = Timer(self._max_data_age, SkipMissedAndDrift())
165+
166+
# Send initial status
167+
await self._status_sender.send(
168+
ComponentStatus(self._component_id, self._last_status)
169+
)
170+
171+
async for selected in select(
172+
ev_data_rx, set_power_result_rx, missing_data_timer
173+
):
174+
new_status = ComponentStatusEnum.NOT_WORKING
175+
if selected_from(selected, ev_data_rx):
176+
missing_data_timer.reset()
177+
new_status = self._handle_ev_data(selected.message)
178+
elif selected_from(selected, set_power_result_rx):
179+
new_status = self._handle_set_power_result(selected.message)
180+
elif selected_from(selected, missing_data_timer):
181+
_logger.warning(
182+
"No EV charger %s data received for %s. Setting status to NOT_WORKING.",
183+
self._component_id,
184+
self._max_data_age,
185+
)
186+
187+
# Send status update if status changed
188+
if (
189+
self._blocking_status.is_blocked()
190+
and new_status != ComponentStatusEnum.NOT_WORKING
191+
):
192+
new_status = ComponentStatusEnum.UNCERTAIN
193+
194+
if new_status != self._last_status:
195+
_logger.info(
196+
"EV charger %s status changed from %s to %s",
197+
self._component_id,
198+
self._last_status,
199+
new_status,
200+
)
201+
self._last_status = new_status
202+
await self._status_sender.send(
203+
ComponentStatus(self._component_id, new_status)
204+
)
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for EVChargerStatusTracker."""
5+
6+
import asyncio
7+
from datetime import datetime, timedelta, timezone
8+
9+
from frequenz.channels import Broadcast
10+
from frequenz.client.microgrid import EVChargerCableState, EVChargerComponentState
11+
from pytest_mock import MockerFixture
12+
13+
from frequenz.sdk._internal._asyncio import cancel_and_await
14+
from frequenz.sdk.actor.power_distributing._component_status import (
15+
ComponentStatus,
16+
ComponentStatusEnum,
17+
EVChargerStatusTracker,
18+
SetPowerResult,
19+
)
20+
21+
from ....timeseries.mock_microgrid import MockMicrogrid
22+
from ....utils.component_data_wrapper import EvChargerDataWrapper
23+
from ....utils.receive_timeout import Timeout, receive_timeout
24+
25+
_EV_CHARGER_ID = 6
26+
27+
28+
class TestEVChargerStatusTracker:
29+
"""Tests for EVChargerStatusTracker."""
30+
31+
async def test_status_changes(self, mocker: MockerFixture) -> None:
32+
"""Test that the status changes as expected."""
33+
mock_microgrid = MockMicrogrid(grid_meter=True, mocker=mocker)
34+
mock_microgrid.add_ev_chargers(3)
35+
36+
status_channel = Broadcast[ComponentStatus](name="battery_status")
37+
set_power_result_channel = Broadcast[SetPowerResult](name="set_power_result")
38+
set_power_result_sender = set_power_result_channel.new_sender()
39+
40+
async with (
41+
mock_microgrid,
42+
EVChargerStatusTracker(
43+
component_id=_EV_CHARGER_ID,
44+
max_data_age=timedelta(seconds=0.2),
45+
max_blocking_duration=timedelta(seconds=1),
46+
status_sender=status_channel.new_sender(),
47+
set_power_result_receiver=set_power_result_channel.new_receiver(),
48+
),
49+
):
50+
status_receiver = status_channel.new_receiver()
51+
# The status is initially not working.
52+
assert (
53+
await status_receiver.receive()
54+
).value == ComponentStatusEnum.NOT_WORKING
55+
56+
# When an EV is plugged, it is working
57+
await mock_microgrid.mock_client.send(
58+
EvChargerDataWrapper(
59+
_EV_CHARGER_ID,
60+
datetime.now(tz=timezone.utc),
61+
active_power=0.0,
62+
component_state=EVChargerComponentState.READY,
63+
cable_state=EVChargerCableState.EV_PLUGGED,
64+
)
65+
)
66+
assert await receive_timeout(status_receiver) == ComponentStatus(
67+
_EV_CHARGER_ID, ComponentStatusEnum.WORKING
68+
)
69+
70+
# When an EV is locked, no change in status
71+
await mock_microgrid.mock_client.send(
72+
EvChargerDataWrapper(
73+
_EV_CHARGER_ID,
74+
datetime.now(tz=timezone.utc),
75+
active_power=0.0,
76+
component_state=EVChargerComponentState.READY,
77+
cable_state=EVChargerCableState.EV_LOCKED,
78+
)
79+
)
80+
assert await receive_timeout(status_receiver) is Timeout
81+
82+
# When an EV is unplugged, it is not working
83+
await mock_microgrid.mock_client.send(
84+
EvChargerDataWrapper(
85+
_EV_CHARGER_ID,
86+
datetime.now(tz=timezone.utc),
87+
active_power=0.0,
88+
component_state=EVChargerComponentState.READY,
89+
cable_state=EVChargerCableState.UNPLUGGED,
90+
)
91+
)
92+
assert await receive_timeout(status_receiver) == ComponentStatus(
93+
_EV_CHARGER_ID, ComponentStatusEnum.NOT_WORKING
94+
)
95+
96+
# Get it back to working again
97+
await mock_microgrid.mock_client.send(
98+
EvChargerDataWrapper(
99+
_EV_CHARGER_ID,
100+
datetime.now(tz=timezone.utc),
101+
active_power=0.0,
102+
component_state=EVChargerComponentState.READY,
103+
cable_state=EVChargerCableState.EV_LOCKED,
104+
)
105+
)
106+
assert await receive_timeout(status_receiver) == ComponentStatus(
107+
_EV_CHARGER_ID, ComponentStatusEnum.WORKING
108+
)
109+
110+
# When there's no new data, it should become not working
111+
assert await receive_timeout(status_receiver, 0.1) is Timeout
112+
assert await receive_timeout(status_receiver, 0.2) == ComponentStatus(
113+
_EV_CHARGER_ID, ComponentStatusEnum.NOT_WORKING
114+
)
115+
116+
# Get it back to working again
117+
await asyncio.sleep(0.1)
118+
await mock_microgrid.mock_client.send(
119+
EvChargerDataWrapper(
120+
_EV_CHARGER_ID,
121+
datetime.now(tz=timezone.utc),
122+
active_power=0.0,
123+
component_state=EVChargerComponentState.READY,
124+
cable_state=EVChargerCableState.EV_LOCKED,
125+
)
126+
)
127+
assert await receive_timeout(status_receiver) == ComponentStatus(
128+
_EV_CHARGER_ID, ComponentStatusEnum.WORKING
129+
)
130+
131+
async def keep_sending_healthy_message() -> None:
132+
while True:
133+
await mock_microgrid.mock_client.send(
134+
EvChargerDataWrapper(
135+
_EV_CHARGER_ID,
136+
datetime.now(tz=timezone.utc),
137+
active_power=0.0,
138+
component_state=EVChargerComponentState.READY,
139+
cable_state=EVChargerCableState.EV_LOCKED,
140+
)
141+
)
142+
await asyncio.sleep(0.1)
143+
144+
_keep_sending_healthy_message_task = asyncio.create_task(
145+
keep_sending_healthy_message()
146+
)
147+
148+
# when there's a PowerDistributor failure for the component, status should
149+
# become uncertain.
150+
await set_power_result_sender.send(
151+
SetPowerResult(
152+
succeeded=set(),
153+
failed={_EV_CHARGER_ID},
154+
)
155+
)
156+
assert await receive_timeout(status_receiver) == ComponentStatus(
157+
_EV_CHARGER_ID, ComponentStatusEnum.UNCERTAIN
158+
)
159+
160+
# After the blocking duration, it should become working again.
161+
assert await receive_timeout(status_receiver) is Timeout
162+
assert await receive_timeout(status_receiver, 1.0) == ComponentStatus(
163+
_EV_CHARGER_ID, ComponentStatusEnum.WORKING
164+
)
165+
await cancel_and_await(_keep_sending_healthy_message_task)

0 commit comments

Comments
 (0)