Skip to main content

frequenz_microgrid/microgrid/
pv_pool.rs

1// License: MIT
2// Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3
4//! Representation of a pool of PV inverters in the microgrid.
5//!
6//! A [`PvPool`] aggregates a set of PV inverters — either an explicit subset or
7//! every PV inverter in the microgrid — and exposes their combined active
8//! power, their aggregated active-power bounds, and a health-partitioned
9//! telemetry snapshot stream.
10//!
11//! Obtain one from [`Microgrid::pv_pool`]; see [`PvPool`] for a usage example.
12//!
13//! [`Microgrid::pv_pool`]: crate::Microgrid::pv_pool
14
15use tokio::sync::broadcast;
16
17use std::collections::{BTreeSet, HashSet};
18use std::time::Duration;
19
20use crate::{
21    Bounds, Error, Formula, LogicalMeterHandle, MicrogridClientHandle,
22    client::proto::common::microgrid::electrical_components::ElectricalComponentStateCode,
23    metric,
24    metric::Metric,
25    microgrid::{
26        caching_sender::{CachingSender, WeakCachingSender},
27        pool_bounds,
28        pool_bounds_tracker::PoolBoundsTracker,
29        pool_validation::validate_pool_ids,
30        telemetry_tracker::pv_pool_telemetry_tracker::{PvPoolSnapshot, PvPoolTelemetryTracker},
31    },
32    quantity::Power,
33};
34
35/// A pool of PV inverters in the microgrid.
36///
37/// Created with [`Microgrid::pv_pool`][mg], passing either an explicit set of PV
38/// inverter component IDs or `None` to cover every PV inverter in the microgrid.
39/// It exposes:
40///
41/// - [`power`](Self::power) — a [`Formula`] for the pool's aggregate active
42///   power;
43/// - [`power_bounds`](Self::power_bounds) — a stream of the pool's aggregated
44///   active-power bounds;
45/// - [`telemetry_snapshots`](Self::telemetry_snapshots) — a stream of
46///   [`PvPoolSnapshot`]s partitioning the inverters into healthy and unhealthy
47///   sets.
48///
49/// The bounds and snapshot streams share a telemetry tracker that is started on
50/// first use and reused while it still has live receivers.
51///
52/// # Example
53///
54/// ```no_run
55/// # async fn example() -> Result<(), frequenz_microgrid::Error> {
56/// use chrono::TimeDelta;
57/// use frequenz_microgrid::{LogicalMeterConfig, Microgrid};
58///
59/// let microgrid = Microgrid::try_new(
60///     "grpc://localhost:50051",
61///     LogicalMeterConfig::new(TimeDelta::try_seconds(1).unwrap()),
62/// )
63/// .await?;
64///
65/// // A pool over every PV inverter in the microgrid.
66/// let mut pv_pool = microgrid.pv_pool(None)?;
67///
68/// // Subscribe to the pool's aggregated active-power bounds.
69/// let mut bounds_rx = pv_pool.power_bounds();
70/// while let Ok(bounds) = bounds_rx.recv().await {
71///     println!("PV pool active-power bounds: {bounds:?}");
72/// }
73/// # Ok(())
74/// # }
75/// ```
76///
77/// [mg]: crate::Microgrid::pv_pool
78pub struct PvPool {
79    component_ids: Option<BTreeSet<u64>>,
80    client: MicrogridClientHandle,
81    logical_meter: LogicalMeterHandle,
82    snapshot_tx: Option<WeakCachingSender<PvPoolSnapshot>>,
83    bounds_tx: Option<WeakCachingSender<Vec<Bounds<Power>>>>,
84}
85
86impl PvPool {
87    /// Creates a new `PvPool` instance with the given component IDs, client and
88    /// logical meter handles.
89    ///
90    /// When `component_ids` is `Some`, every ID must refer to a PV inverter in
91    /// the component graph; otherwise an error is returned. When it is `None`,
92    /// the pool covers all PV inverters in the microgrid.
93    pub(crate) fn try_new(
94        component_ids: Option<BTreeSet<u64>>,
95        client: MicrogridClientHandle,
96        logical_meter: LogicalMeterHandle,
97    ) -> Result<Self, Error> {
98        let this = Self {
99            component_ids,
100            client,
101            logical_meter,
102            snapshot_tx: None,
103            bounds_tx: None,
104        };
105        validate_pool_ids(
106            &this.component_ids,
107            &this.get_all_pv_inverter_ids(),
108            "PV inverters",
109        )
110        .inspect_err(|e| tracing::error!("{e}"))?;
111        Ok(this)
112    }
113
114    fn get_all_pv_inverter_ids(&self) -> BTreeSet<u64> {
115        self.logical_meter
116            .graph()
117            .components()
118            .filter(|c| c.is_pv_inverter())
119            .map(|c| c.id)
120            .collect()
121    }
122
123    pub(crate) fn get_pv_inverter_ids(&self) -> BTreeSet<u64> {
124        if let Some(ids) = &self.component_ids {
125            ids.clone()
126        } else {
127            self.get_all_pv_inverter_ids()
128        }
129    }
130
131    /// Returns a formula for the active power of the PV pool.
132    pub fn power(&mut self) -> Result<Formula<Power>, Error> {
133        self.logical_meter
134            .pv::<metric::AcPowerActive>(self.component_ids.clone())
135    }
136
137    /// Returns a receiver for the aggregated active-power bounds of the pool,
138    /// updated on each snapshot.
139    ///
140    /// Reuses the running bounds tracker if one exists and still has active
141    /// receivers; otherwise starts a new one (which also starts or reuses the
142    /// underlying telemetry tracker).
143    pub fn power_bounds(&mut self) -> broadcast::Receiver<Vec<Bounds<Power>>> {
144        if let Some(tx) = self.bounds_tx.as_ref().and_then(WeakCachingSender::upgrade)
145            && tx.receiver_count() > 0
146        {
147            return tx.subscribe_with_current();
148        }
149        let snapshot_rx = self.telemetry_snapshots();
150        let tx = CachingSender::<Vec<Bounds<Power>>>::new();
151        // Subscribe before spawning so the tracker sees a receiver and doesn't
152        // stop before this consumer has read anything.
153        let rx = tx.subscribe_with_current();
154        let tracker = PoolBoundsTracker::new(
155            snapshot_rx,
156            tx.clone(),
157            pool_bounds::compute_pv_pool_bounds::<metric::AcPowerActive>,
158            format!("{} PV", metric::AcPowerActive::str_name()),
159        );
160        tokio::spawn(tracker.run());
161        self.bounds_tx = Some(tx.downgrade());
162        rx
163    }
164
165    /// Returns a receiver for a stream of [`PvPoolSnapshot`] values, each
166    /// reflecting the latest inverter telemetry partitioned into healthy and
167    /// unhealthy sets.
168    ///
169    /// Reuses the running tracker if one exists and still has active receivers
170    /// (including any held by a bounds tracker); otherwise starts a new one.
171    pub fn telemetry_snapshots(&mut self) -> broadcast::Receiver<PvPoolSnapshot> {
172        if let Some(tx) = self
173            .snapshot_tx
174            .as_ref()
175            .and_then(WeakCachingSender::upgrade)
176            && tx.receiver_count() > 0
177        {
178            return tx.subscribe_with_current();
179        }
180        let tx = CachingSender::<PvPoolSnapshot>::new();
181        // Subscribe before spawning so the tracker sees a receiver and doesn't
182        // stop before this consumer has read anything.
183        let rx = tx.subscribe_with_current();
184        let tracker = PvPoolTelemetryTracker::new(
185            self.get_pv_inverter_ids(),
186            Duration::from_secs(10),
187            // Operational states in which a PV inverter is alive and
188            // reporting usable telemetry: producing (Discharging), or idle
189            // and ready (Ready / Standby).
190            HashSet::from([
191                ElectricalComponentStateCode::Ready,
192                ElectricalComponentStateCode::Standby,
193                ElectricalComponentStateCode::Discharging,
194            ]),
195            self.client.clone(),
196            tx.clone(),
197        );
198        tokio::spawn(tracker.run());
199        self.snapshot_tx = Some(tx.downgrade());
200        rx
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use std::collections::BTreeSet;
207
208    use super::PvPool;
209    use crate::client::test_utils::MockComponent;
210    use crate::microgrid::test_utils::{handles, last_snapshot};
211
212    /// grid → meter → [pv meter → pv_inverter(4), pv_inverter(5)],
213    ///                 [battery meter → battery_inverter(7) → battery(8)]
214    fn graph() -> MockComponent {
215        MockComponent::grid(1).with_children(vec![MockComponent::meter(2).with_children(vec![
216            MockComponent::meter(3).with_children(vec![
217                MockComponent::pv_inverter(4),
218                MockComponent::pv_inverter(5),
219            ]),
220            MockComponent::meter(6).with_children(vec![
221                MockComponent::battery_inverter(7).with_children(vec![MockComponent::battery(8)]),
222            ]),
223        ])])
224    }
225
226    #[tokio::test]
227    async fn try_new_accepts_empty_component_ids() {
228        let (client, lm) = handles(graph()).await;
229        // An explicit empty selection is a valid (empty) pool, not an error.
230        let mut pool = PvPool::try_new(Some(BTreeSet::new()), client, lm)
231            .expect("an empty component_ids set should yield an empty pool");
232        pool.power().expect("empty pool power formula");
233    }
234
235    #[tokio::test(start_paused = true)]
236    async fn empty_pool_emits_empty_snapshot_and_bounds() {
237        // grid → meter, with no PV inverters anywhere.
238        let (client, lm) =
239            handles(MockComponent::grid(1).with_children(vec![MockComponent::meter(2)])).await;
240        let mut pool = PvPool::try_new(None, client, lm).unwrap();
241
242        let mut snapshots = pool.telemetry_snapshots();
243        let mut bounds = pool.power_bounds();
244
245        let snapshot = last_snapshot(&mut snapshots, 5).await;
246        assert!(
247            snapshot.inverters.healthy.is_empty() && snapshot.inverters.unhealthy.is_empty(),
248            "empty pool snapshot should have no inverters, got {snapshot:?}"
249        );
250
251        let bounds = last_snapshot(&mut bounds, 5).await;
252        assert!(
253            bounds.is_empty(),
254            "empty pool should have empty power bounds"
255        );
256    }
257
258    #[tokio::test(start_paused = true)]
259    async fn late_subscriber_sees_latest_snapshot() {
260        let (client, lm) = handles(graph()).await;
261        let mut pool = PvPool::try_new(None, client, lm).unwrap();
262
263        // Drive the tracker so it has published a non-initial snapshot (the two
264        // PV inverters carry no telemetry, so they settle into the unhealthy
265        // set).
266        let mut early = pool.telemetry_snapshots();
267        let early_snap = last_snapshot(&mut early, 10).await;
268        assert_eq!(
269            early_snap.inverters.unhealthy.len(),
270            2,
271            "precondition: both inverters tracked"
272        );
273
274        // A subscriber joining after that publish must immediately observe the
275        // current snapshot — `subscribe` re-sends the cached value, so it neither
276        // blocks waiting for a change nor sees an empty stream.
277        let mut late = pool.telemetry_snapshots();
278        let late_snap = late
279            .try_recv()
280            .expect("late subscriber should be sent the cached snapshot at once");
281        assert_eq!(
282            late_snap, early_snap,
283            "late subscriber should see the latest snapshot, not an empty stream"
284        );
285    }
286
287    #[tokio::test(start_paused = true)]
288    async fn resubscribing_after_teardown_yields_a_current_valued_stream() {
289        let (client, lm) = handles(graph()).await;
290        let mut pool = PvPool::try_new(None, client, lm).unwrap();
291
292        // Subscribe, drive to a real snapshot, then drop the only consumer so
293        // the tracker stops (its next tick finds no receivers).
294        let mut rx = pool.telemetry_snapshots();
295        assert_eq!(
296            last_snapshot(&mut rx, 10).await.inverters.unhealthy.len(),
297            2
298        );
299        drop(rx);
300        tokio::time::advance(std::time::Duration::from_secs(1)).await;
301
302        // Resubscribe: with the previous tracker stopped, the pool starts a fresh
303        // one, so the stream is immediately usable again — delivering the pool's
304        // current snapshot instead of hanging.
305        let mut rx = pool.telemetry_snapshots();
306        assert_eq!(
307            last_snapshot(&mut rx, 10).await.inverters.unhealthy.len(),
308            2,
309            "resubscribed stream should yield the pool's current snapshot"
310        );
311    }
312
313    #[tokio::test(start_paused = true)]
314    async fn calling_power_bounds_twice_reuses_the_tracker() {
315        let (client, lm) = handles(graph()).await;
316        let mut pool = PvPool::try_new(None, client, lm).unwrap();
317
318        // First call starts the bounds tracker; drive it so it caches a value.
319        let mut rx1 = pool.power_bounds();
320        let bounds1 = last_snapshot(&mut rx1, 10).await;
321
322        // A second call while rx1 is still alive must reuse the running tracker
323        // (its weak sender upgrades and still has a receiver). Reuse re-sends the
324        // cached bounds at once; a freshly spawned tracker's cache would be empty
325        // until it ran, so an immediate `try_recv` succeeds only on the reuse path.
326        let mut rx2 = pool.power_bounds();
327        let bounds2 = rx2
328            .try_recv()
329            .expect("reused tracker should re-send its cached bounds immediately");
330        assert_eq!(bounds1, bounds2, "reused tracker shares the same bounds");
331    }
332
333    #[tokio::test]
334    async fn try_new_rejects_non_pv_component_ids() {
335        let (client, lm) = handles(graph()).await;
336        // 7 is a battery inverter and 8 a battery — neither is a PV inverter.
337        let err = PvPool::try_new(Some([4, 7, 8].into()), client, lm)
338            .err()
339            .expect("non-PV component_ids should be rejected");
340        assert!(
341            err.to_string().contains("must be PV inverters"),
342            "unexpected error: {err}"
343        );
344    }
345
346    #[tokio::test]
347    async fn power_formula_for_explicit_pv_inverters() {
348        let (client, lm) = handles(graph()).await;
349        let mut pool = PvPool::try_new(Some([4, 5].into()), client, lm).unwrap();
350        let formula = pool.power().unwrap();
351        assert_eq!(
352            formula.to_string(),
353            "METRIC_AC_POWER_ACTIVE::(COALESCE(#5 + #4, #3, COALESCE(#5, 0.0) + COALESCE(#4, 0.0)))"
354        );
355    }
356
357    #[tokio::test]
358    async fn power_formula_for_all_pv_inverters() {
359        let (client, lm) = handles(graph()).await;
360        let mut pool = PvPool::try_new(None, client, lm).unwrap();
361        let formula = pool.power().unwrap();
362        assert_eq!(
363            formula.to_string(),
364            "METRIC_AC_POWER_ACTIVE::(COALESCE(#5 + #4, #3, COALESCE(#5, 0.0) + COALESCE(#4, 0.0)))"
365        );
366    }
367}