Skip to main content

frequenz_microgrid/microgrid/
battery_pool.rs

1// License: MIT
2// Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3
4//! Representation of a pool of batteries in the microgrid.
5
6use tokio::sync::broadcast;
7
8use std::collections::{BTreeSet, HashSet};
9use std::time::Duration;
10
11use crate::{
12    Bounds, Error, Formula, LogicalMeterHandle, MicrogridClientHandle,
13    client::{
14        ElectricalComponentCategory,
15        proto::common::microgrid::electrical_components::ElectricalComponentStateCode,
16    },
17    metric,
18    metric::Metric,
19    microgrid::{
20        caching_sender::{CachingSender, WeakCachingSender},
21        pool_bounds,
22        pool_bounds_tracker::PoolBoundsTracker,
23        pool_validation::validate_pool_ids,
24        telemetry_tracker::battery_pool_telemetry_tracker::{
25            BatteryPoolSnapshot, BatteryPoolTelemetryTracker,
26        },
27    },
28    quantity::Power,
29};
30
31/// An interface for abstracting over a pool of batteries in the microgrid.
32pub struct BatteryPool {
33    component_ids: Option<BTreeSet<u64>>,
34    client: MicrogridClientHandle,
35    logical_meter: LogicalMeterHandle,
36    snapshot_tx: Option<WeakCachingSender<BatteryPoolSnapshot>>,
37    bounds_tx: Option<WeakCachingSender<Vec<Bounds<Power>>>>,
38}
39
40impl BatteryPool {
41    /// Creates a new `BatteryPool` instance with the given component IDs,
42    /// client and logical meter handles.
43    pub(crate) fn try_new(
44        component_ids: Option<BTreeSet<u64>>,
45        client: MicrogridClientHandle,
46        logical_meter: LogicalMeterHandle,
47    ) -> Result<Self, Error> {
48        let this = Self {
49            component_ids,
50            client,
51            logical_meter,
52            snapshot_tx: None,
53            bounds_tx: None,
54        };
55        validate_pool_ids(
56            &this.component_ids,
57            &this.get_all_battery_ids(),
58            "batteries",
59        )
60        .inspect_err(|e| tracing::error!("{e}"))?;
61        // Reject malformed or partial selections (e.g. only one battery of an
62        // inverter-battery group) at construction, rather than surfacing the
63        // error later from the spawned telemetry tracker as a closed stream.
64        // Errors are logged inside `inverter_battery_groups`.
65        BatteryPoolTelemetryTracker::inverter_battery_groups(
66            this.logical_meter.graph(),
67            &this.get_battery_ids(),
68        )?;
69        Ok(this)
70    }
71
72    fn get_all_battery_ids(&self) -> BTreeSet<u64> {
73        self.logical_meter
74            .graph()
75            .components()
76            .filter(|c| c.category() == ElectricalComponentCategory::Battery)
77            .map(|c| c.id)
78            .collect()
79    }
80
81    pub(crate) fn get_battery_ids(&self) -> BTreeSet<u64> {
82        if let Some(ids) = &self.component_ids {
83            ids.clone()
84        } else {
85            self.get_all_battery_ids()
86        }
87    }
88
89    /// Returns a formula for the active power of the battery pool.
90    pub fn power(&mut self) -> Result<Formula<Power>, Error> {
91        self.logical_meter
92            .battery::<metric::AcPowerActive>(self.component_ids.clone())
93    }
94
95    /// Returns a receiver for the aggregated active-power bounds of the pool,
96    /// updated on each snapshot.
97    ///
98    /// Reuses the running bounds tracker if one exists and still has active
99    /// receivers; otherwise starts a new one (which also starts or reuses the
100    /// underlying telemetry tracker).
101    pub fn power_bounds(&mut self) -> broadcast::Receiver<Vec<Bounds<Power>>> {
102        if let Some(tx) = self.bounds_tx.as_ref().and_then(WeakCachingSender::upgrade)
103            && tx.receiver_count() > 0
104        {
105            return tx.subscribe_with_current();
106        }
107        let snapshot_rx = self.telemetry_snapshots();
108        let tx = CachingSender::<Vec<Bounds<Power>>>::new();
109        // Subscribe before spawning so the tracker sees a receiver and doesn't
110        // stop before this consumer has read anything.
111        let rx = tx.subscribe_with_current();
112        let tracker = PoolBoundsTracker::new(
113            snapshot_rx,
114            tx.clone(),
115            pool_bounds::compute_battery_pool_bounds::<metric::AcPowerActive, metric::DcPower>,
116            format!(
117                "{}/{}",
118                metric::AcPowerActive::str_name(),
119                metric::DcPower::str_name()
120            ),
121        );
122        tokio::spawn(tracker.run());
123        self.bounds_tx = Some(tx.downgrade());
124        rx
125    }
126
127    /// Returns a receiver for a stream of [`BatteryPoolSnapshot`] values,
128    /// each reflecting the latest component telemetry partitioned into
129    /// healthy and unhealthy sets.
130    ///
131    /// Reuses the running tracker if one exists and still has active receivers
132    /// (including any held by a bounds tracker); otherwise starts a new one.
133    pub fn telemetry_snapshots(&mut self) -> broadcast::Receiver<BatteryPoolSnapshot> {
134        if let Some(tx) = self
135            .snapshot_tx
136            .as_ref()
137            .and_then(WeakCachingSender::upgrade)
138            && tx.receiver_count() > 0
139        {
140            return tx.subscribe_with_current();
141        }
142        let tx = CachingSender::<BatteryPoolSnapshot>::new();
143        // Subscribe before spawning so the tracker sees a receiver and doesn't
144        // stop before this consumer has read anything.
145        let rx = tx.subscribe_with_current();
146        let tracker = BatteryPoolTelemetryTracker::new(
147            self.get_battery_ids(),
148            Duration::from_secs(10),
149            HashSet::from([
150                ElectricalComponentStateCode::Ready,
151                ElectricalComponentStateCode::Standby,
152                ElectricalComponentStateCode::Charging,
153                ElectricalComponentStateCode::Discharging,
154                ElectricalComponentStateCode::RelayClosed,
155            ]),
156            self.client.clone(),
157            self.logical_meter.clone(),
158            tx.clone(),
159        );
160        tokio::spawn(tracker.run());
161        self.snapshot_tx = Some(tx.downgrade());
162        rx
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::BatteryPool;
169    use crate::client::test_utils::MockComponent;
170    use crate::microgrid::test_utils::{handles, last_snapshot};
171
172    /// grid → meter, with no batteries anywhere.
173    fn battery_less_graph() -> MockComponent {
174        MockComponent::grid(1).with_children(vec![MockComponent::meter(2)])
175    }
176
177    #[tokio::test]
178    async fn try_new_none_constructs_an_empty_pool_without_batteries() {
179        let (client, lm) = handles(battery_less_graph()).await;
180        // A battery-less microgrid is a valid (empty) pool, not an error.
181        let mut pool = BatteryPool::try_new(None, client, lm)
182            .expect("a battery-less microgrid should yield an empty pool");
183        pool.power().expect("empty pool power formula");
184    }
185
186    #[tokio::test(start_paused = true)]
187    async fn empty_pool_emits_empty_snapshot_and_bounds() {
188        let (client, lm) = handles(battery_less_graph()).await;
189        let mut pool = BatteryPool::try_new(None, client, lm).unwrap();
190
191        let mut snapshots = pool.telemetry_snapshots();
192        let mut bounds = pool.power_bounds();
193
194        let snapshot = last_snapshot(&mut snapshots, 5).await;
195        assert!(
196            snapshot.groups().is_empty(),
197            "empty pool snapshot should have no groups, got {snapshot:?}"
198        );
199
200        let bounds = last_snapshot(&mut bounds, 5).await;
201        assert!(
202            bounds.is_empty(),
203            "empty pool should have empty power bounds"
204        );
205    }
206
207    /// grid → meter → battery_inverter(3) → [battery(4), battery(5)]
208    fn shared_inverter_graph() -> MockComponent {
209        MockComponent::grid(1).with_children(vec![MockComponent::meter(2).with_children(vec![
210                MockComponent::battery_inverter(3).with_children(vec![
211                    MockComponent::battery(4),
212                    MockComponent::battery(5),
213                ]),
214            ])])
215    }
216
217    #[tokio::test]
218    async fn try_new_rejects_partial_inverter_battery_group() {
219        // Battery 4 shares inverter 3 with battery 5, so selecting only 4 is a
220        // malformed selection. It must be rejected at construction rather than
221        // silently surfacing later as an empty snapshot/bounds value.
222        let (client, lm) = handles(shared_inverter_graph()).await;
223        assert!(
224            BatteryPool::try_new(Some([4].into()), client, lm).is_err(),
225            "a partial inverter-battery group must be rejected"
226        );
227    }
228}