Skip to content

Commit 353247d

Browse files
committed
Add a component status tracker for PV inverters
Signed-off-by: Sahas Subramanian <[email protected]>
1 parent 9fd4865 commit 353247d

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
@@ -12,6 +12,7 @@
1212
SetPowerResult,
1313
)
1414
from ._ev_charger_status_tracker import EVChargerStatusTracker
15+
from ._pv_inverter_status_tracker import PVInverterStatusTracker
1516

1617
__all__ = [
1718
"BatteryStatusTracker",
@@ -20,5 +21,6 @@
2021
"ComponentStatusEnum",
2122
"ComponentStatusTracker",
2223
"EVChargerStatusTracker",
24+
"PVInverterStatusTracker",
2325
"SetPowerResult",
2426
]
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Background service that tracks the status of a PV inverter."""
5+
6+
import asyncio
7+
import logging
8+
from datetime import datetime, timedelta, timezone
9+
10+
# Component state for inverters and batteries is not wrapped by the
11+
# microgrid client currently, so it needs to be imported directly from
12+
# the api repo.
13+
# pylint: disable=no-name-in-module
14+
from frequenz.api.microgrid.inverter_pb2 import (
15+
ComponentState as PbInverterComponentState,
16+
)
17+
18+
# pylint: enable=no-name-in-module
19+
from frequenz.channels import Receiver, Sender, select, selected_from
20+
from frequenz.channels.timer import SkipMissedAndDrift, Timer
21+
from frequenz.client.microgrid import InverterData
22+
from typing_extensions import override
23+
24+
from ....microgrid import connection_manager
25+
from ..._background_service import BackgroundService
26+
from ._blocking_status import BlockingStatus
27+
from ._component_status import (
28+
ComponentStatus,
29+
ComponentStatusEnum,
30+
ComponentStatusTracker,
31+
SetPowerResult,
32+
)
33+
34+
_logger = logging.getLogger(__name__)
35+
36+
37+
class PVInverterStatusTracker(ComponentStatusTracker, BackgroundService):
38+
"""Status tracker for PV inverters.
39+
40+
It reports a PV inverter as `WORKING` or `NOT_WORKING` based on
41+
the status in the received component data from the microgrid API.
42+
When no data is received for a specific duration, the component is
43+
marked as `NOT_WORKING`.
44+
45+
If it receives a power assignment failure from the PowerDistributor,
46+
when the component is expected to be `WORKING`, it is marked as
47+
`UNCERTAIN` for a specific interval, before being marked `WORKING`
48+
again.
49+
"""
50+
51+
@override
52+
def __init__( # pylint: disable=too-many-arguments
53+
self,
54+
component_id: int,
55+
max_data_age: timedelta,
56+
max_blocking_duration: timedelta,
57+
status_sender: Sender[ComponentStatus],
58+
set_power_result_receiver: Receiver[SetPowerResult],
59+
) -> None:
60+
"""Initialize this instance.
61+
62+
Args:
63+
component_id: ID of the PV inverter to monitor the status of.
64+
max_data_age: max duration to wait for, before marking a component as
65+
NOT_WORKING, unless new data arrives.
66+
max_blocking_duration: max duration to wait for, before marking a component
67+
as BLOCKING, unless new data arrives.
68+
status_sender: Sender to send the status of the PV inverter.
69+
set_power_result_receiver: Receiver for the power assignment result.
70+
"""
71+
self._component_id = component_id
72+
self._max_data_age = max_data_age
73+
self._status_sender = status_sender
74+
self._set_power_result_receiver = set_power_result_receiver
75+
76+
self._last_status = ComponentStatusEnum.NOT_WORKING
77+
self._blocking_status = BlockingStatus(
78+
min_duration=timedelta(seconds=1.0),
79+
max_duration=max_blocking_duration,
80+
)
81+
82+
BackgroundService.__init__(
83+
self, name=f"PVInverterStatusTracker({component_id})"
84+
)
85+
86+
@override
87+
def start(self) -> None:
88+
"""Start the status tracker."""
89+
self._tasks.add(asyncio.create_task(self._run_forever()))
90+
91+
def _is_working(self, pv_data: InverterData) -> bool:
92+
"""Return whether the given data indicates that the PV inverter is working."""
93+
return pv_data._component_state in ( # pylint: disable=protected-access
94+
PbInverterComponentState.COMPONENT_STATE_DISCHARGING,
95+
PbInverterComponentState.COMPONENT_STATE_CHARGING,
96+
PbInverterComponentState.COMPONENT_STATE_IDLE,
97+
PbInverterComponentState.COMPONENT_STATE_STANDBY,
98+
)
99+
100+
async def _run_forever(self) -> None:
101+
while True:
102+
try:
103+
await self._run()
104+
except Exception: # pylint: disable=broad-except
105+
_logger.exception(
106+
"Restarting after exception in PVInverterStatusTracker.run()"
107+
)
108+
await asyncio.sleep(1.0)
109+
110+
def _is_stale(self, pv_data: InverterData) -> bool:
111+
"""Return whether the given data is stale."""
112+
now = datetime.now(tz=timezone.utc)
113+
stale = now - pv_data.timestamp > self._max_data_age
114+
return stale
115+
116+
def _handle_set_power_result(
117+
self, set_power_result: SetPowerResult
118+
) -> ComponentStatusEnum:
119+
"""Handle a new set power result."""
120+
if self._component_id in set_power_result.succeeded:
121+
return ComponentStatusEnum.WORKING
122+
123+
self._blocking_status.block()
124+
if self._last_status == ComponentStatusEnum.WORKING:
125+
_logger.warning(
126+
"PV inverter %s is in UNCERTAIN state. Set power result: %s",
127+
self._component_id,
128+
set_power_result,
129+
)
130+
return ComponentStatusEnum.UNCERTAIN
131+
132+
def _handle_pv_inverter_data(self, pv_data: InverterData) -> ComponentStatusEnum:
133+
"""Handle new PV inverter data."""
134+
if self._is_stale(pv_data):
135+
if self._last_status == ComponentStatusEnum.WORKING:
136+
_logger.warning(
137+
"PV inverter %s data is stale. Last timestamp: %s",
138+
self._component_id,
139+
pv_data.timestamp,
140+
)
141+
return ComponentStatusEnum.NOT_WORKING
142+
143+
if self._is_working(pv_data):
144+
if self._last_status == ComponentStatusEnum.NOT_WORKING:
145+
_logger.warning(
146+
"PV inverter %s is in WORKING state.",
147+
self._component_id,
148+
)
149+
return ComponentStatusEnum.WORKING
150+
151+
if self._last_status == ComponentStatusEnum.WORKING:
152+
_logger.warning(
153+
"PV inverter %s is in NOT_WORKING state. Component state: %s",
154+
self._component_id,
155+
pv_data._component_state, # pylint: disable=protected-access
156+
)
157+
return ComponentStatusEnum.NOT_WORKING
158+
159+
async def _run(self) -> None:
160+
"""Run the status tracker."""
161+
api_client = connection_manager.get().api_client
162+
pv_data_rx = await api_client.inverter_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+
pv_data_rx, set_power_result_rx, missing_data_timer
173+
):
174+
new_status = ComponentStatusEnum.NOT_WORKING
175+
if selected_from(selected, pv_data_rx):
176+
missing_data_timer.reset()
177+
new_status = self._handle_pv_inverter_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 PV inverter %s data received for %s. "
183+
"Setting status to NOT_WORKING.",
184+
self._component_id,
185+
self._max_data_age,
186+
)
187+
188+
# Send status update if status changed
189+
if (
190+
self._blocking_status.is_blocked()
191+
and new_status != ComponentStatusEnum.NOT_WORKING
192+
):
193+
new_status = ComponentStatusEnum.UNCERTAIN
194+
195+
if new_status != self._last_status:
196+
_logger.info(
197+
"EV charger %s status changed from %s to %s",
198+
self._component_id,
199+
self._last_status,
200+
new_status,
201+
)
202+
self._last_status = new_status
203+
await self._status_sender.send(
204+
ComponentStatus(self._component_id, new_status)
205+
)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for PVInverterStatusTracker."""
5+
6+
7+
import asyncio
8+
from datetime import datetime, timedelta, timezone
9+
10+
# pylint: disable=no-name-in-module
11+
from frequenz.api.microgrid.inverter_pb2 import (
12+
ComponentState as PbInverterComponentState,
13+
)
14+
15+
# pylint: enable=no-name-in-module
16+
from frequenz.channels import Broadcast
17+
from pytest_mock import MockerFixture
18+
19+
from frequenz.sdk._internal._asyncio import cancel_and_await
20+
from frequenz.sdk.actor.power_distributing._component_status import (
21+
ComponentStatus,
22+
ComponentStatusEnum,
23+
PVInverterStatusTracker,
24+
SetPowerResult,
25+
)
26+
27+
from ....timeseries.mock_microgrid import MockMicrogrid
28+
from ....utils.component_data_wrapper import InverterDataWrapper
29+
from ....utils.receive_timeout import Timeout, receive_timeout
30+
31+
_PV_INVERTER_ID = 8
32+
33+
34+
class TestPVInverterStatusTracker:
35+
"""Tests for PVInverterStatusTracker."""
36+
37+
async def test_status_changes(self, mocker: MockerFixture) -> None:
38+
"""Test that the status changes as expected."""
39+
mock_microgrid = MockMicrogrid(grid_meter=True, mocker=mocker)
40+
mock_microgrid.add_solar_inverters(1)
41+
42+
status_channel = Broadcast[ComponentStatus](name="pv_inverter_status")
43+
set_power_result_channel = Broadcast[SetPowerResult](name="set_power_result")
44+
set_power_result_sender = set_power_result_channel.new_sender()
45+
46+
async with (
47+
mock_microgrid,
48+
PVInverterStatusTracker(
49+
component_id=_PV_INVERTER_ID,
50+
max_data_age=timedelta(seconds=0.2),
51+
max_blocking_duration=timedelta(seconds=1),
52+
status_sender=status_channel.new_sender(),
53+
set_power_result_receiver=set_power_result_channel.new_receiver(),
54+
),
55+
):
56+
status_receiver = status_channel.new_receiver()
57+
# The status is initially not working.
58+
assert (
59+
await status_receiver.receive()
60+
).value == ComponentStatusEnum.NOT_WORKING
61+
62+
# When there's healthy inverter data, status should be working.
63+
await mock_microgrid.mock_client.send(
64+
InverterDataWrapper(
65+
_PV_INVERTER_ID,
66+
datetime.now(tz=timezone.utc),
67+
active_power=0.0,
68+
_component_state=PbInverterComponentState.COMPONENT_STATE_IDLE,
69+
)
70+
)
71+
assert await receive_timeout(status_receiver) == ComponentStatus(
72+
_PV_INVERTER_ID, ComponentStatusEnum.WORKING
73+
)
74+
75+
# When it is discharging, there should be no change in status
76+
await mock_microgrid.mock_client.send(
77+
InverterDataWrapper(
78+
_PV_INVERTER_ID,
79+
datetime.now(tz=timezone.utc),
80+
active_power=0.0,
81+
_component_state=PbInverterComponentState.COMPONENT_STATE_DISCHARGING,
82+
)
83+
)
84+
assert await receive_timeout(status_receiver) is Timeout
85+
86+
# When there an error message, status should be not working
87+
await mock_microgrid.mock_client.send(
88+
InverterDataWrapper(
89+
_PV_INVERTER_ID,
90+
datetime.now(tz=timezone.utc),
91+
active_power=0.0,
92+
_component_state=PbInverterComponentState.COMPONENT_STATE_ERROR,
93+
)
94+
)
95+
assert await receive_timeout(status_receiver) == ComponentStatus(
96+
_PV_INVERTER_ID, ComponentStatusEnum.NOT_WORKING
97+
)
98+
99+
# Get it back to working again
100+
await mock_microgrid.mock_client.send(
101+
InverterDataWrapper(
102+
_PV_INVERTER_ID,
103+
datetime.now(tz=timezone.utc),
104+
active_power=0.0,
105+
_component_state=PbInverterComponentState.COMPONENT_STATE_IDLE,
106+
)
107+
)
108+
assert await receive_timeout(status_receiver) == ComponentStatus(
109+
_PV_INVERTER_ID, ComponentStatusEnum.WORKING
110+
)
111+
112+
# When there's no new data, status should be not working
113+
assert await receive_timeout(status_receiver, 0.1) is Timeout
114+
assert await receive_timeout(status_receiver, 0.2) == ComponentStatus(
115+
_PV_INVERTER_ID, ComponentStatusEnum.NOT_WORKING
116+
)
117+
118+
# Get it back to working again
119+
await mock_microgrid.mock_client.send(
120+
InverterDataWrapper(
121+
_PV_INVERTER_ID,
122+
datetime.now(tz=timezone.utc),
123+
active_power=0.0,
124+
_component_state=PbInverterComponentState.COMPONENT_STATE_IDLE,
125+
)
126+
)
127+
assert await receive_timeout(status_receiver) == ComponentStatus(
128+
_PV_INVERTER_ID, ComponentStatusEnum.WORKING
129+
)
130+
131+
async def keep_sending_healthy_message() -> None:
132+
"""Keep sending healthy messages."""
133+
while True:
134+
await mock_microgrid.mock_client.send(
135+
InverterDataWrapper(
136+
_PV_INVERTER_ID,
137+
datetime.now(tz=timezone.utc),
138+
active_power=0.0,
139+
_component_state=PbInverterComponentState.COMPONENT_STATE_IDLE,
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+
# when there's a PowerDistributor failure for the component, status should
148+
# become uncertain.
149+
await set_power_result_sender.send(
150+
SetPowerResult(
151+
succeeded=set(),
152+
failed={_PV_INVERTER_ID},
153+
)
154+
)
155+
assert await receive_timeout(status_receiver) == ComponentStatus(
156+
_PV_INVERTER_ID, ComponentStatusEnum.UNCERTAIN
157+
)
158+
159+
# After the blocking duration, it should become working again.
160+
assert await receive_timeout(status_receiver) is Timeout
161+
assert await receive_timeout(status_receiver, 1.0) == ComponentStatus(
162+
_PV_INVERTER_ID, ComponentStatusEnum.WORKING
163+
)
164+
await cancel_and_await(_keep_sending_healthy_message_task)

0 commit comments

Comments
 (0)