Skip to main content

frequenz_microgrid/microgrid/telemetry_tracker/
pv_pool_telemetry_tracker.rs

1// License: MIT
2// Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3
4//! A telemetry tracker for a pool of PV inverters.
5//!
6//! The tracker spawns a [`ComponentTelemetryTracker`] per inverter and emits a
7//! [`PvPoolSnapshot`], partitioning the inverters into healthy and unhealthy
8//! sets, whenever any inverter's telemetry or health classification changes.
9
10use std::{
11    collections::{BTreeSet, HashSet},
12    time::Duration,
13};
14
15use tokio::sync::mpsc;
16
17use crate::{
18    MicrogridClientHandle,
19    client::proto::common::microgrid::electrical_components::ElectricalComponentStateCode,
20    microgrid::caching_sender::CachingSender,
21};
22
23use super::component_partition::ComponentHealthPartition;
24use super::component_telemetry_tracker::{ComponentHealthStatus, ComponentTelemetryTracker};
25
26/// A snapshot of a PV pool's inverters, partitioned by health status and
27/// annotated with the latest telemetry sample for each (see
28/// [`ComponentHealthPartition`]).
29#[derive(Clone, Debug, Default, PartialEq)]
30pub struct PvPoolSnapshot {
31    pub inverters: ComponentHealthPartition,
32}
33
34/// A tracker that watches every PV inverter in the pool and emits a
35/// [`PvPoolSnapshot`] whenever any inverter's telemetry or health
36/// classification changes.
37#[derive(Clone)]
38pub(crate) struct PvPoolTelemetryTracker {
39    component_ids: BTreeSet<u64>,
40    component_pool_status_tx: CachingSender<PvPoolSnapshot>,
41    missing_data_tolerance: Duration,
42    healthy_state_codes: HashSet<ElectricalComponentStateCode>,
43    client: MicrogridClientHandle,
44}
45
46impl PvPoolTelemetryTracker {
47    pub(crate) fn new(
48        component_ids: BTreeSet<u64>,
49        missing_data_tolerance: Duration,
50        healthy_state_codes: HashSet<ElectricalComponentStateCode>,
51        client: MicrogridClientHandle,
52        component_pool_status_tx: CachingSender<PvPoolSnapshot>,
53    ) -> Self {
54        Self {
55            component_ids,
56            component_pool_status_tx,
57            missing_data_tolerance,
58            healthy_state_codes,
59            client,
60        }
61    }
62
63    pub(crate) async fn run(self) {
64        let mut snapshot = PvPoolSnapshot::default();
65        for &inverter_id in &self.component_ids {
66            // Every inverter starts unhealthy until it reports data.
67            snapshot.inverters.mark_unhealthy(inverter_id, None);
68        }
69
70        // Publish the initial partition before opening any telemetry streams, so
71        // a subscriber reading before the first update sees the pool's real
72        // inverters (all unhealthy until data arrives) rather than the channel's
73        // empty default. For an empty pool this is the single empty snapshot.
74        // A fresh subscriber gets it (or its cached copy) at once. Ignore "no
75        // receivers" here — the tick loop below owns shutdown.
76        let _ = self.component_pool_status_tx.publish(snapshot.clone());
77
78        let (status_tx, mut status_rx) = mpsc::channel(100);
79        for &inverter_id in &self.component_ids {
80            let component_data_stream = match self
81                .client
82                .receive_electrical_component_telemetry_stream(inverter_id)
83                .await
84            {
85                Ok(stream) => stream,
86                Err(e) => {
87                    tracing::error!(
88                        "Internal error opening telemetry stream for inverter {inverter_id}: {e}; PV pool telemetry tracker aborting.",
89                    );
90                    return;
91                }
92            };
93            let tracker = ComponentTelemetryTracker::new(
94                inverter_id,
95                self.missing_data_tolerance,
96                self.healthy_state_codes.clone(),
97                component_data_stream,
98                status_tx.clone(),
99            );
100            // Spawn a task for each component telemetry tracker.
101            tokio::spawn(async move {
102                tracker.run().await;
103            });
104        }
105
106        // Drop the original sender so the channel closes once every component
107        // tracker finishes, ending the loop below. An empty pool spawns no
108        // trackers, so keep the sender instead: `status_rx.recv()` then parks,
109        // and the tick loop drives the (empty) snapshot and the receiver-count
110        // shutdown check — so the task stops when its consumers go, not before.
111        let _empty_pool_keepalive = if self.component_ids.is_empty() {
112            Some(status_tx)
113        } else {
114            drop(status_tx);
115            None
116        };
117
118        let mut interval = tokio::time::interval(Duration::from_millis(200));
119
120        loop {
121            tokio::select! {
122                maybe_status = status_rx.recv() => {
123                    match maybe_status {
124                        Some(ComponentHealthStatus::Healthy(id, data)) => {
125                            snapshot.inverters.mark_healthy(id, data);
126                        }
127                        Some(ComponentHealthStatus::Unhealthy(id, data)) => {
128                            snapshot.inverters.mark_unhealthy(id, data);
129                        }
130                        // Every component tracker has exited and dropped its
131                        // sender, so no further updates will ever arrive. The
132                        // `_ = interval.tick()` arm below is a catch-all that
133                        // never disables, so the `select!` `else` branch can
134                        // never run; break here instead.
135                        None => break,
136                    }
137                },
138                _ = interval.tick() => {
139                    // Publish only when the partition changed (compared whole, so
140                    // a future field can't escape detection); either way, stop
141                    // once the last consumer has dropped.
142                    if !self.component_pool_status_tx.publish_if_changed(&snapshot) {
143                        break;
144                    }
145                },
146            }
147        }
148
149        // Reaching here means either every consumer dropped or every component
150        // tracker exited — a normal shutdown, not an error.
151        tracing::debug!(
152            "PvPoolTelemetryTracker (component IDs {:?}) stopped: all consumers or component trackers are gone.",
153            self.component_ids
154        );
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use crate::client::proto::common::microgrid::electrical_components::ElectricalComponentStateCode;
161    use crate::client::test_utils::MockComponent;
162    use crate::microgrid::pv_pool::PvPool;
163    use crate::microgrid::test_utils::{handles, last_snapshot};
164
165    async fn new_pool(graph: MockComponent) -> PvPool {
166        let (client, lm) = handles(graph).await;
167        PvPool::try_new(None, client, lm).unwrap()
168    }
169
170    #[tokio::test(start_paused = true)]
171    async fn single_inverter_reaches_healthy_state() {
172        // grid → meter → pv_inverter(3)
173        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
174            MockComponent::meter(2).with_children(vec![
175                MockComponent::pv_inverter(3).with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
176            ]),
177        ]))
178        .await;
179
180        let mut rx = pool.telemetry_snapshots();
181        let snap = last_snapshot(&mut rx, 10).await;
182
183        assert!(snap.inverters.healthy.contains_key(&3));
184        assert!(snap.inverters.unhealthy.is_empty());
185    }
186
187    #[tokio::test(start_paused = true)]
188    async fn two_inverters_both_appear_in_snapshot() {
189        // grid → meter → [pv_inverter(3), pv_inverter(4)]
190        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
191            MockComponent::meter(2).with_children(vec![
192                MockComponent::pv_inverter(3).with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
193                MockComponent::pv_inverter(4).with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
194            ]),
195        ]))
196        .await;
197
198        let mut rx = pool.telemetry_snapshots();
199        let snap = last_snapshot(&mut rx, 10).await;
200
201        assert!(snap.inverters.healthy.contains_key(&3));
202        assert!(snap.inverters.healthy.contains_key(&4));
203        assert!(snap.inverters.unhealthy.is_empty());
204    }
205
206    #[tokio::test(start_paused = true)]
207    async fn calling_telemetry_snapshots_twice_reuses_sender() {
208        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
209            MockComponent::meter(2).with_children(vec![
210                MockComponent::pv_inverter(3).with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
211            ]),
212        ]))
213        .await;
214
215        let mut rx1 = pool.telemetry_snapshots();
216        let mut rx2 = pool.telemetry_snapshots();
217
218        // Advance so the tracker publishes at least one snapshot.
219        tokio::time::advance(std::time::Duration::from_millis(300)).await;
220
221        let snap1 = last_snapshot(&mut rx1, 0).await;
222        let snap2 = last_snapshot(&mut rx2, 0).await;
223        assert_eq!(
224            snap1, snap2,
225            "both subscriptions should observe the same snapshot"
226        );
227    }
228
229    #[tokio::test(start_paused = true)]
230    async fn inverter_becomes_unhealthy_when_data_stops() {
231        // A handful of samples then silence; the stream stays open so the
232        // client actor doesn't reconnect and resupply data.
233        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
234            MockComponent::meter(2).with_children(vec![
235                MockComponent::pv_inverter(3)
236                    .with_power(vec![0.0, 0.0, 0.0])
237                    .with_silence_after_metrics(),
238            ]),
239        ]))
240        .await;
241
242        let mut rx = pool.telemetry_snapshots();
243
244        // First confirm the inverter reaches a healthy state.
245        let healthy = last_snapshot(&mut rx, 10).await;
246        assert!(
247            healthy.inverters.healthy.contains_key(&3),
248            "expected inverter to go healthy after initial samples, got {:?}",
249            healthy
250        );
251
252        // Advance well past the 10s missing-data tolerance — the component
253        // tracker should fire its interval and reclassify the inverter.
254        tokio::time::advance(std::time::Duration::from_secs(15)).await;
255        let unhealthy = last_snapshot(&mut rx, 5).await;
256
257        assert!(
258            unhealthy.inverters.healthy.is_empty(),
259            "inverter should be unhealthy after data stops, got healthy set {:?}",
260            unhealthy.inverters.healthy.keys()
261        );
262        assert!(unhealthy.inverters.unhealthy.contains_key(&3));
263    }
264
265    #[tokio::test(start_paused = true)]
266    async fn inverter_with_bad_state_is_unhealthy() {
267        // Inverter reports an Error state — it must land in the unhealthy set
268        // even though samples keep arriving.
269        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
270            MockComponent::meter(2).with_children(vec![
271                MockComponent::pv_inverter(3)
272                    .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
273                    .with_state(ElectricalComponentStateCode::Error),
274            ]),
275        ]))
276        .await;
277
278        let mut rx = pool.telemetry_snapshots();
279        let snap = last_snapshot(&mut rx, 10).await;
280
281        assert!(
282            !snap.inverters.healthy.contains_key(&3),
283            "inverter with Error state should not be in healthy set"
284        );
285        assert!(
286            snap.inverters.unhealthy.contains_key(&3),
287            "inverter with Error state should be in unhealthy set, got {:?}",
288            snap
289        );
290    }
291}