Skip to content

Commit 3a94ed0

Browse files
Extract mixin from PV and EV component managers (#1403)
PV inverter manager, EV charger manager and also battery manager have similar functionality for setting power levels in the microgrid API. These have drifted apart to different degrees, especially the battery manager implementation differs a lot. The other two were close enough to extract them into a util function. This will be reusable when we introduce a similar manager for steam boilers.
2 parents 56c23d7 + f9ab525 commit 3a94ed0

3 files changed

Lines changed: 128 additions & 154 deletions

File tree

src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_ev_charger_manager/_ev_charger_manager.py

Lines changed: 9 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
selected_from,
1818
)
1919
from frequenz.client.common.microgrid.components import ComponentId
20-
from frequenz.client.microgrid import ApiClientError, MicrogridApiClient
2120
from frequenz.client.microgrid.component import EvCharger
2221
from frequenz.quantities import Power, Voltage
2322
from typing_extensions import override
@@ -30,8 +29,9 @@
3029
from ..._component_pool_status_tracker import ComponentPoolStatusTracker
3130
from ..._component_status import ComponentPoolStatus, EVChargerStatusTracker
3231
from ...request import Request
33-
from ...result import PartialFailure, Result, Success
32+
from ...result import Result
3433
from .._component_manager import ComponentManager
34+
from .._utils import _set_component_power
3535
from ._config import EVDistributionConfig
3636
from ._states import EvcState, EvcStates
3737

@@ -283,85 +283,15 @@ async def _run(self) -> None: # pylint: disable=too-many-locals
283283
self._evc_states.get(component_id).update_last_allocation(power, now)
284284

285285
latest_target_powers.update(target_power_changes)
286-
result = await self._set_api_power(
287-
api, target_power_changes, self._api_power_request_timeout
288-
)
289-
await self._results_sender.send(result)
290-
291-
async def _set_api_power(
292-
self,
293-
api: MicrogridApiClient,
294-
target_power_changes: dict[ComponentId, Power],
295-
api_request_timeout: timedelta,
296-
) -> Result:
297-
"""Send the EV charger power changes to the microgrid API.
298-
299-
Args:
300-
api: The microgrid API client to use for setting the power.
301-
target_power_changes: A dictionary containing the new power allocations for
302-
the EV chargers.
303-
api_request_timeout: The timeout for the API request.
304-
305-
Returns:
306-
Power distribution result, corresponding to the result of the API
307-
request.
308-
"""
309-
tasks: dict[ComponentId, asyncio.Task[datetime | None]] = {}
310-
for component_id, power in target_power_changes.items():
311-
tasks[component_id] = asyncio.create_task(
312-
api.set_component_power_active(component_id, power.as_watts())
313-
)
314-
_, pending = await asyncio.wait(
315-
tasks.values(),
316-
timeout=api_request_timeout.total_seconds(),
317-
return_when=asyncio.ALL_COMPLETED,
318-
)
319-
for task in pending:
320-
task.cancel()
321-
await asyncio.gather(*pending, return_exceptions=True)
322-
323-
failed_components: set[ComponentId] = set()
324-
succeeded_components: set[ComponentId] = set()
325-
failed_power = Power.zero()
326-
for component_id, task in tasks.items():
327-
try:
328-
task.result()
329-
except asyncio.CancelledError:
330-
_logger.warning(
331-
"Timeout while setting power to EV charger %s", component_id
332-
)
333-
except ApiClientError as exc:
334-
_logger.warning(
335-
"Got a client error while setting power to EV charger %s: %s",
336-
component_id,
337-
exc,
338-
)
339-
except Exception: # pylint: disable=broad-except
340-
_logger.exception(
341-
"Unknown error while setting power to EV charger: %s", component_id
342-
)
343-
else:
344-
succeeded_components.add(component_id)
345-
continue
346-
347-
failed_components.add(component_id)
348-
failed_power += target_power_changes[component_id]
349-
350-
if failed_components:
351-
return PartialFailure(
352-
failed_components=failed_components,
353-
succeeded_components=succeeded_components,
354-
failed_power=failed_power,
355-
succeeded_power=self._target_power - failed_power,
356-
excess_power=Power.zero(),
286+
result = await _set_component_power(
357287
request=self._latest_request,
288+
target_power=self._target_power,
289+
allocations=target_power_changes,
290+
api_request_timeout=self._api_power_request_timeout,
291+
remaining_power=Power.zero(),
292+
component_category="EV charger",
358293
)
359-
return Success(
360-
succeeded_components=succeeded_components,
361-
succeeded_power=self._target_power,
362-
excess_power=Power.zero(),
363-
request=self._latest_request,
364-
)
294+
await self._results_sender.send(result)
365295

366296
def _deallocate_unused_power(
367297
self, to_deallocate: Power

src/frequenz/sdk/microgrid/_power_distributing/_component_managers/_pv_inverter_manager/_pv_inverter_manager.py

Lines changed: 11 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@
66
import asyncio
77
import collections.abc
88
import logging
9-
from datetime import datetime, timedelta
9+
from datetime import timedelta
1010

1111
from frequenz.channels import LatestValueCache, Sender
1212
from frequenz.client.common.microgrid.components import ComponentId
13-
from frequenz.client.microgrid import ApiClientError
1413
from frequenz.client.microgrid.component import SolarInverter
1514
from frequenz.quantities import Power
1615
from typing_extensions import override
@@ -21,8 +20,9 @@
2120
from ..._component_pool_status_tracker import ComponentPoolStatusTracker
2221
from ..._component_status import ComponentPoolStatus, PVInverterStatusTracker
2322
from ...request import Request
24-
from ...result import PartialFailure, Result, Success
23+
from ...result import Result, Success
2524
from .._component_manager import ComponentManager
25+
from .._utils import _set_component_power
2626

2727
_logger = logging.getLogger(__name__)
2828

@@ -176,79 +176,15 @@ async def distribute_power(self, request: Request) -> None:
176176
request.power,
177177
allocations,
178178
)
179-
await self._set_api_power(request, allocations, remaining_power)
180-
181-
async def _set_api_power( # pylint: disable=too-many-locals
182-
self,
183-
request: Request,
184-
allocations: dict[ComponentId, Power],
185-
remaining_power: Power,
186-
) -> None:
187-
api_client = connection_manager.get().api_client
188-
tasks: dict[ComponentId, asyncio.Task[datetime | None]] = {}
189-
for component_id, power in allocations.items():
190-
tasks[component_id] = asyncio.create_task(
191-
api_client.set_component_power_active(component_id, power.as_watts())
192-
)
193-
_, pending = await asyncio.wait(
194-
tasks.values(),
195-
timeout=self._api_power_request_timeout.total_seconds(),
196-
return_when=asyncio.ALL_COMPLETED,
197-
)
198-
# collect the timed out tasks and cancel them while keeping the
199-
# exceptions, so that they can be processed later.
200-
for task in pending:
201-
task.cancel()
202-
await asyncio.gather(*pending, return_exceptions=True)
203-
204-
failed_components: set[ComponentId] = set()
205-
succeeded_components: set[ComponentId] = set()
206-
failed_power = Power.zero()
207-
for component_id, task in tasks.items():
208-
try:
209-
task.result()
210-
except asyncio.CancelledError:
211-
_logger.warning(
212-
"Timeout while setting power to PV inverter %s", component_id
213-
)
214-
except ApiClientError as exc:
215-
_logger.warning(
216-
"Got a client error while setting power to PV inverter %s: %s",
217-
component_id,
218-
exc,
219-
)
220-
except Exception: # pylint: disable=broad-except
221-
_logger.exception(
222-
"Unknown error while setting power to PV inverter: %s",
223-
component_id,
224-
)
225-
else:
226-
succeeded_components.add(component_id)
227-
continue
228-
229-
failed_components.add(component_id)
230-
failed_power += allocations[component_id]
231-
232-
if failed_components:
233-
await self._results_sender.send(
234-
PartialFailure(
235-
failed_components=failed_components,
236-
succeeded_components=succeeded_components,
237-
failed_power=failed_power,
238-
succeeded_power=request.power - failed_power - remaining_power,
239-
excess_power=remaining_power,
240-
request=request,
241-
)
242-
)
243-
return
244-
await self._results_sender.send(
245-
Success(
246-
succeeded_components=succeeded_components,
247-
succeeded_power=request.power - remaining_power,
248-
excess_power=remaining_power,
249-
request=request,
250-
)
179+
result = await _set_component_power(
180+
request=request,
181+
target_power=request.power,
182+
allocations=allocations,
183+
api_request_timeout=self._api_power_request_timeout,
184+
remaining_power=remaining_power,
185+
component_category="PV inverter",
251186
)
187+
await self._results_sender.send(result)
252188

253189
def _get_pv_inverter_ids(self) -> collections.abc.Set[ComponentId]:
254190
"""Return the IDs of all PV inverters present in the component graph."""
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# License: MIT
2+
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Utility for component managers."""
5+
6+
import asyncio
7+
import logging
8+
from datetime import datetime, timedelta
9+
10+
from frequenz.client.base.exception import ApiClientError
11+
from frequenz.client.common.microgrid.components import ComponentId
12+
from frequenz.quantities import Power
13+
14+
from ... import connection_manager
15+
from ..request import Request
16+
from ..result import PartialFailure, Result, Success
17+
18+
_logger = logging.getLogger(__name__)
19+
20+
21+
async def _set_component_power( # pylint: disable=too-many-locals,too-many-arguments
22+
*,
23+
request: Request,
24+
target_power: Power,
25+
allocations: dict[ComponentId, Power],
26+
api_request_timeout: timedelta,
27+
remaining_power: Power,
28+
component_category: str,
29+
) -> Result:
30+
"""Send the component power changes to the microgrid API.
31+
32+
Args:
33+
request: Set-power request sent to the `PowerDistributingActor`.
34+
target_power: The requested power.
35+
allocations: A dictionary containing the new power allocations for
36+
each component.
37+
api_request_timeout: The timeout for the API request.
38+
remaining_power: Any excess (remaining) power.
39+
component_category: Component category name, for display purposes.
40+
41+
Returns:
42+
Power distribution result, corresponding to the result of the API
43+
request.
44+
"""
45+
api_client = connection_manager.get().api_client
46+
tasks: dict[ComponentId, asyncio.Task[datetime | None]] = {}
47+
for component_id, power in allocations.items():
48+
tasks[component_id] = asyncio.create_task(
49+
api_client.set_component_power_active(component_id, power.as_watts())
50+
)
51+
_, pending = await asyncio.wait(
52+
tasks.values(),
53+
timeout=api_request_timeout.total_seconds(),
54+
return_when=asyncio.ALL_COMPLETED,
55+
)
56+
# collect the timed out tasks and cancel them while keeping the
57+
# exceptions, so that they can be processed later.
58+
for task in pending:
59+
task.cancel()
60+
await asyncio.gather(*pending, return_exceptions=True)
61+
62+
failed_components: set[ComponentId] = set()
63+
succeeded_components: set[ComponentId] = set()
64+
failed_power = Power.zero()
65+
for component_id, task in tasks.items():
66+
try:
67+
task.result()
68+
except asyncio.CancelledError:
69+
_logger.warning(
70+
"Timeout while setting power to %s %s",
71+
component_category,
72+
component_id,
73+
)
74+
except ApiClientError as exc:
75+
_logger.warning(
76+
"Got a client error while setting power to %s %s: %s",
77+
component_category,
78+
component_id,
79+
exc,
80+
)
81+
except Exception: # pylint: disable=broad-except
82+
_logger.exception(
83+
"Unknown error while setting power to %s: %s",
84+
component_category,
85+
component_id,
86+
)
87+
else:
88+
succeeded_components.add(component_id)
89+
continue
90+
91+
failed_components.add(component_id)
92+
failed_power += allocations[component_id]
93+
94+
if failed_components:
95+
return PartialFailure(
96+
failed_components=failed_components,
97+
succeeded_components=succeeded_components,
98+
failed_power=failed_power,
99+
succeeded_power=target_power - failed_power - remaining_power,
100+
excess_power=remaining_power,
101+
request=request,
102+
)
103+
return Success(
104+
succeeded_components=succeeded_components,
105+
succeeded_power=target_power - remaining_power,
106+
excess_power=remaining_power,
107+
request=request,
108+
)

0 commit comments

Comments
 (0)