Skip to content

Commit cd5282e

Browse files
authored
Add a steam boiler pool (#1429)
Steam boilers are controllable electrical loads the SDK could not monitor or manage. This adds a steam boiler pool with power readings and power control, following the structure of the EV charger and PV pools. ## Changes - New public API: `microgrid.new_steam_boiler_pool()` returning a `SteamBoilerPool` with `power`, `power_status`, and a consumption-only `propose_power` (negative power is rejected). - `SteamBoilerData` component data type and data sourcing support. - Power manager wiring using the plain matryoshka algorithm, with `DefaultPower.ZERO` when no actor is proposing. - `SteamBoilerManager` splits the target power equally across the working boilers, keeping every allocation inside the boiler's inclusion bounds: a share below a boiler's minimum operating power is raised to the minimum, or the boiler is kept off. Like the PV manager, it subtracts the measured draw of unreachable boilers from the target. - `SteamBoilerStatusTracker` for per-boiler health, mirroring the PV tracker. ## Worth a look - The single-pass allocation reports power as excess when a high-minimum boiler can't be afforded instead of topping up earlier boilers (`test_unaffordable_minimum_strands_excess` pins this). - With no usable boilers a request is dropped with only an error log — the same behavior as the PV and EV managers; a follow-up could make all three send an `Error` result the way the battery manager does.
2 parents 39d1e33 + b7d7402 commit cd5282e

28 files changed

Lines changed: 2562 additions & 9 deletions

‎RELEASE_NOTES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
## New Features
1414

15-
<!-- Here goes the main new features and examples or instructions on how to use them -->
15+
* Added `SteamBoilerPool` for monitoring and controlling pools of steam boilers, available via `microgrid.new_steam_boiler_pool()`.
1616

1717
## Bug Fixes
1818

‎src/frequenz/sdk/microgrid/__init__.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,14 +138,30 @@
138138
which accepts values in the {{glossary("psc", "Passive Sign Convention")}} and supports
139139
only charging.
140140
141+
## Steam Boilers
142+
143+
The [`steam_boiler_pool`][frequenz.sdk.microgrid.new_steam_boiler_pool] offers a
144+
[`power`][frequenz.sdk.timeseries.steam_boiler_pool.SteamBoilerPool.power] method that
145+
streams the total power measured for all the steam boilers at a site.
146+
147+
The `steam_boiler_pool` also provides available power bounds through the
148+
[`power_status`][frequenz.sdk.timeseries.steam_boiler_pool.SteamBoilerPool.power_status]
149+
method.
150+
151+
The `steam_boiler_pool` also provides a control method
152+
[`propose_power`][frequenz.sdk.timeseries.steam_boiler_pool.SteamBoilerPool.propose_power],
153+
which accepts values in the {{glossary("psc", "Passive Sign Convention")}} and supports
154+
only consumption.
155+
141156
# Component pools
142157
143158
The SDK provides a unified interface for interacting with sets of Batteries, EV
144-
chargers and PV arrays, through their corresponding `Pool`s.
159+
chargers, PV arrays and steam boilers, through their corresponding `Pool`s.
145160
146161
* [Battery pool][frequenz.sdk.microgrid.new_battery_pool]
147162
* [EV charger pool][frequenz.sdk.microgrid.new_ev_charger_pool]
148163
* [PV pool][frequenz.sdk.microgrid.new_pv_pool]
164+
* [Steam boiler pool][frequenz.sdk.microgrid.new_steam_boiler_pool]
149165
150166
All of them provide support for streaming aggregated data and for setting the
151167
power values of the components.
@@ -366,6 +382,7 @@
366382
| Batteries | Zero |
367383
| PV | Max production (Min power according to PSC) |
368384
| EV Chargers | Max consumption (Max power according to PSC) |
385+
| Steam Boilers | Zero |
369386
""" # noqa: D205, D400, E501
370387

371388
from datetime import timedelta
@@ -382,6 +399,7 @@
382399
new_battery_pool,
383400
new_ev_charger_pool,
384401
new_pv_pool,
402+
new_steam_boiler_pool,
385403
producer,
386404
voltage_per_phase,
387405
)
@@ -442,6 +460,7 @@ async def initialize(
442460
"new_battery_pool",
443461
"new_ev_charger_pool",
444462
"new_pv_pool",
463+
"new_steam_boiler_pool",
445464
"producer",
446465
"voltage_per_phase",
447466
]

‎src/frequenz/sdk/microgrid/_data_pipeline.py‎

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919

2020
from frequenz.channels import Broadcast, Sender
2121
from frequenz.client.common.microgrid.components import ComponentId
22-
from frequenz.client.microgrid.component import Battery, EvCharger, SolarInverter
22+
from frequenz.client.microgrid.component import (
23+
Battery,
24+
EvCharger,
25+
SolarInverter,
26+
SteamBoiler,
27+
)
2328

2429
from .._internal._channels import ChannelRegistry
2530
from ..actor._actor import Actor
@@ -52,6 +57,10 @@
5257
from ..timeseries.producer import Producer
5358
from ..timeseries.pv_pool import PVPool
5459
from ..timeseries.pv_pool._pv_pool_reference_store import PVPoolReferenceStore
60+
from ..timeseries.steam_boiler_pool import SteamBoilerPool
61+
from ..timeseries.steam_boiler_pool._steam_boiler_pool_reference_store import (
62+
SteamBoilerPoolReferenceStore,
63+
)
5564

5665
_logger = logging.getLogger(__name__)
5766

@@ -131,6 +140,13 @@ def __init__(
131140
# https://github.com/frequenz-floss/frequenz-sdk-python/issues/1285
132141
component_class=SolarInverter,
133142
)
143+
self._steam_boiler_power_wrapper = PowerWrapper(
144+
self._channel_registry,
145+
api_power_request_timeout=api_power_request_timeout,
146+
power_manager_algorithm=PowerManagerAlgorithm.MATRYOSHKA,
147+
default_power=DefaultPower.ZERO,
148+
component_class=SteamBoiler,
149+
)
134150

135151
self._logical_meter: LogicalMeter | None = None
136152
self._consumer: Consumer | None = None
@@ -145,6 +161,9 @@ def __init__(
145161
self._pv_pool_reference_stores: dict[
146162
frozenset[ComponentId], PVPoolReferenceStore
147163
] = {}
164+
self._steam_boiler_pool_reference_stores: dict[
165+
frozenset[ComponentId], SteamBoilerPoolReferenceStore
166+
] = {}
148167
self._frequency_instance: GridFrequency | None = None
149168
self._voltage_instance: VoltageStreamer | None = None
150169

@@ -476,6 +495,83 @@ def new_battery_pool(
476495
priority=priority,
477496
)
478497

498+
def new_steam_boiler_pool(
499+
self,
500+
*,
501+
priority: int,
502+
component_ids: abc.Set[ComponentId] | None = None,
503+
name: str | None = None,
504+
) -> SteamBoilerPool:
505+
"""Return a new `SteamBoilerPool` instance for the given ids.
506+
507+
If a `SteamBoilerPoolReferenceStore` instance for the given steam boiler ids
508+
doesn't exist, a new one is created and used for creating the
509+
`SteamBoilerPool`.
510+
511+
Args:
512+
priority: The priority of the actor making the call.
513+
component_ids: Optional set of IDs of steam boilers to be managed by the
514+
SteamBoilerPool.
515+
name: An optional name used to identify this instance of the pool or a
516+
corresponding actor in the logs.
517+
518+
Returns:
519+
A SteamBoilerPool instance.
520+
"""
521+
from ..timeseries.steam_boiler_pool import SteamBoilerPool
522+
from ..timeseries.steam_boiler_pool._steam_boiler_pool_reference_store import (
523+
SteamBoilerPoolReferenceStore,
524+
)
525+
526+
if not self._steam_boiler_power_wrapper.started:
527+
self._steam_boiler_power_wrapper.start()
528+
529+
# We use frozenset to make a hashable key from the input set.
530+
ref_store_key: frozenset[ComponentId] = frozenset()
531+
if component_ids is not None:
532+
ref_store_key = frozenset(component_ids)
533+
534+
pool_key = f"{ref_store_key}-{priority}"
535+
if pool_key in self._known_pool_keys:
536+
_logger.warning(
537+
"A SteamBoilerPool instance was already created for steam_boiler_ids=%s "
538+
"and priority=%s using `microgrid.steam_boiler_pool(...)`."
539+
"\n Hint: If the multiple instances are created from the same actor, "
540+
"consider reusing the same instance."
541+
"\n Hint: If the instances are created from different actors, "
542+
"consider using different priorities to distinguish them.",
543+
component_ids,
544+
priority,
545+
)
546+
else:
547+
self._known_pool_keys.add(pool_key)
548+
549+
if ref_store_key not in self._steam_boiler_pool_reference_stores:
550+
self._steam_boiler_pool_reference_stores[ref_store_key] = (
551+
SteamBoilerPoolReferenceStore(
552+
channel_registry=self._channel_registry,
553+
resampler_subscription_sender=self._resampling_request_sender(),
554+
status_receiver=self._steam_boiler_power_wrapper.status_channel.new_receiver(
555+
limit=1
556+
),
557+
power_manager_requests_sender=(
558+
self._steam_boiler_power_wrapper.proposal_channel.new_sender()
559+
),
560+
power_manager_bounds_subs_sender=(
561+
self._steam_boiler_power_wrapper.bounds_subscription_channel.new_sender()
562+
),
563+
power_distribution_results_fetcher=(
564+
self._steam_boiler_power_wrapper.distribution_results_fetcher()
565+
),
566+
component_ids=component_ids,
567+
)
568+
)
569+
return SteamBoilerPool(
570+
pool_ref_store=self._steam_boiler_pool_reference_stores[ref_store_key],
571+
name=name,
572+
priority=priority,
573+
)
574+
479575
def _data_sourcing_request_sender(self) -> Sender[ComponentMetricRequest]:
480576
"""Return a Sender for sending requests to the data sourcing actor.
481577
@@ -532,12 +628,15 @@ async def _stop(self) -> None:
532628
await self._battery_power_wrapper.stop()
533629
await self._ev_power_wrapper.stop()
534630
await self._pv_power_wrapper.stop()
631+
await self._steam_boiler_power_wrapper.stop()
535632
for pool in self._battery_pool_reference_stores.values():
536633
await pool.stop()
537634
for evpool in self._ev_charger_pool_reference_stores.values():
538635
await evpool.stop()
539636
for pvpool in self._pv_pool_reference_stores.values():
540637
await pvpool.stop()
638+
for steam_boiler_pool in self._steam_boiler_pool_reference_stores.values():
639+
await steam_boiler_pool.stop()
541640

542641

543642
_DATA_PIPELINE: _DataPipeline | None = None
@@ -724,6 +823,45 @@ def new_pv_pool(
724823
return _get().new_pv_pool(priority=priority, component_ids=component_ids, name=name)
725824

726825

826+
def new_steam_boiler_pool(
827+
*,
828+
priority: int,
829+
component_ids: abc.Set[ComponentId] | None = None,
830+
name: str | None = None,
831+
) -> SteamBoilerPool:
832+
"""Return a new `SteamBoilerPool` instance for the given parameters.
833+
834+
The priority value is used to resolve conflicts when multiple actors are trying to
835+
propose different power values for the same set of steam boilers.
836+
837+
!!! note
838+
When specifying priority, bigger values indicate higher priority.
839+
840+
It is recommended to reuse the same instance of the `SteamBoilerPool` within the same
841+
actor, unless they are managing different sets of steam boilers.
842+
843+
In deployments with multiple actors managing the same set of steam boilers, it is
844+
recommended to use different priorities to distinguish between them. If not,
845+
a random prioritization will be imposed on them to resolve conflicts, which may
846+
lead to unexpected behavior like longer duration to converge on the desired
847+
power.
848+
849+
Args:
850+
priority: The priority of the actor making the call.
851+
component_ids: Optional set of IDs of steam boilers to be managed by the
852+
`SteamBoilerPool`. If not specified, all steam boilers available in the component
853+
graph are used.
854+
name: An optional name used to identify this instance of the pool or a
855+
corresponding actor in the logs.
856+
857+
Returns:
858+
A `SteamBoilerPool` instance.
859+
"""
860+
return _get().new_steam_boiler_pool(
861+
priority=priority, component_ids=component_ids, name=name
862+
)
863+
864+
727865
def grid() -> Grid:
728866
"""Return the grid measuring point."""
729867
return _get().grid()

‎src/frequenz/sdk/microgrid/_data_sourcing/microgrid_api_source.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
EVChargerData,
2525
InverterData,
2626
MeterData,
27+
SteamBoilerData,
2728
TransitionalMetric,
2829
)
2930
from ._component_metric_request import ComponentMetricRequest
@@ -152,6 +153,38 @@
152153
Metric.AC_REACTIVE_POWER_PHASE_3: lambda msg: msg.reactive_power_per_phase[2],
153154
}
154155

156+
_STEAM_BOILER_DATA_METHODS: dict[
157+
Metric | TransitionalMetric, Callable[[SteamBoilerData], float]
158+
] = {
159+
Metric.AC_ACTIVE_POWER: lambda msg: msg.active_power,
160+
Metric.AC_ACTIVE_POWER_PHASE_1: lambda msg: msg.active_power_per_phase[0],
161+
Metric.AC_ACTIVE_POWER_PHASE_2: lambda msg: msg.active_power_per_phase[1],
162+
Metric.AC_ACTIVE_POWER_PHASE_3: lambda msg: msg.active_power_per_phase[2],
163+
TransitionalMetric.ACTIVE_POWER_INCLUSION_LOWER_BOUND: lambda msg: (
164+
msg.active_power_inclusion_lower_bound
165+
),
166+
TransitionalMetric.ACTIVE_POWER_EXCLUSION_LOWER_BOUND: lambda msg: (
167+
msg.active_power_exclusion_lower_bound
168+
),
169+
TransitionalMetric.ACTIVE_POWER_EXCLUSION_UPPER_BOUND: lambda msg: (
170+
msg.active_power_exclusion_upper_bound
171+
),
172+
TransitionalMetric.ACTIVE_POWER_INCLUSION_UPPER_BOUND: lambda msg: (
173+
msg.active_power_inclusion_upper_bound
174+
),
175+
Metric.AC_CURRENT_PHASE_1: lambda msg: msg.current_per_phase[0],
176+
Metric.AC_CURRENT_PHASE_2: lambda msg: msg.current_per_phase[1],
177+
Metric.AC_CURRENT_PHASE_3: lambda msg: msg.current_per_phase[2],
178+
Metric.AC_VOLTAGE_PHASE_1_N: lambda msg: msg.voltage_per_phase[0],
179+
Metric.AC_VOLTAGE_PHASE_2_N: lambda msg: msg.voltage_per_phase[1],
180+
Metric.AC_VOLTAGE_PHASE_3_N: lambda msg: msg.voltage_per_phase[2],
181+
Metric.AC_FREQUENCY: lambda msg: msg.frequency,
182+
Metric.AC_REACTIVE_POWER: lambda msg: msg.reactive_power,
183+
Metric.AC_REACTIVE_POWER_PHASE_1: lambda msg: msg.reactive_power_per_phase[0],
184+
Metric.AC_REACTIVE_POWER_PHASE_2: lambda msg: msg.reactive_power_per_phase[1],
185+
Metric.AC_REACTIVE_POWER_PHASE_3: lambda msg: msg.reactive_power_per_phase[2],
186+
}
187+
155188

156189
class MicrogridApiSource:
157190
"""Fetches requested metrics from the Microgrid API.
@@ -306,6 +339,31 @@ async def _check_chp_request(
306339
connection_manager.get().api_client, comp_id
307340
)
308341

342+
async def _check_steam_boiler_request(
343+
self,
344+
comp_id: ComponentId,
345+
requests: dict[Metric | TransitionalMetric, list[ComponentMetricRequest]],
346+
) -> None:
347+
"""Check if the requests are valid steam boiler metrics.
348+
349+
Raises:
350+
ValueError: if the requested metric is not available for steam boilers.
351+
352+
Args:
353+
comp_id: The id of the requested component.
354+
requests: A list of metric requests received from external actors
355+
for the given steam boiler.
356+
"""
357+
for metric in requests:
358+
if metric not in _STEAM_BOILER_DATA_METHODS:
359+
err = f"Unknown metric {metric} for steam boiler id {comp_id}"
360+
_logger.error(err)
361+
raise ValueError(err)
362+
if comp_id not in self.comp_data_receivers:
363+
self.comp_data_receivers[comp_id] = SteamBoilerData.subscribe(
364+
connection_manager.get().api_client, comp_id
365+
)
366+
309367
async def _check_meter_request(
310368
self,
311369
comp_id: ComponentId,
@@ -362,6 +420,8 @@ async def _check_requested_component_and_metrics(
362420
await self._check_meter_request(comp_id, requests)
363421
elif category == ComponentCategory.CHP:
364422
await self._check_chp_request(comp_id, requests)
423+
elif category == ComponentCategory.STEAM_BOILER:
424+
await self._check_steam_boiler_request(comp_id, requests)
365425
else:
366426
err = f"Unknown component category {category}"
367427
_logger.error(err)
@@ -393,6 +453,8 @@ def _get_data_extraction_method(
393453
return _EV_CHARGER_DATA_METHODS[metric]
394454
if category == ComponentCategory.CHP:
395455
return _CHP_DATA_METHODS[metric]
456+
if category == ComponentCategory.STEAM_BOILER:
457+
return _STEAM_BOILER_DATA_METHODS[metric]
396458
err = f"Unknown component category {category}"
397459
_logger.error(err)
398460
raise ValueError(err)

0 commit comments

Comments
 (0)