Skip to main content

frequenz_microgrid/microgrid/telemetry_tracker/
battery_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 batteries and their associated inverters.
5
6use std::{
7    collections::{BTreeSet, HashMap, HashSet},
8    time::Duration,
9};
10
11use frequenz_microgrid_component_graph::ComponentGraph;
12
13use crate::{
14    Error, LogicalMeterHandle, MicrogridClientHandle,
15    client::proto::common::microgrid::electrical_components::{
16        ElectricalComponent, ElectricalComponentConnection, ElectricalComponentStateCode,
17    },
18    microgrid::caching_sender::CachingSender,
19    microgrid::telemetry_tracker::component_partition::ComponentHealthPartition,
20    microgrid::telemetry_tracker::inverter_battery_group_telemetry_tracker::{
21        InverterBatteryGroupStatus, InverterBatteryGroupTelemetryTracker,
22    },
23};
24
25/// A set of inverters and batteries wired together in an `MxN` configuration:
26/// M inverters in parallel on the AC side, N batteries in parallel on the DC
27/// side, with the inverter side in series with the battery side.
28#[derive(Clone, Debug, Hash, PartialEq, Eq)]
29pub struct InverterBatteryGroup {
30    pub inverter_ids: BTreeSet<u64>,
31    pub battery_ids: BTreeSet<u64>,
32}
33
34impl InverterBatteryGroup {
35    pub(crate) fn new(inverter_ids: BTreeSet<u64>, battery_ids: BTreeSet<u64>) -> Self {
36        Self {
37            inverter_ids,
38            battery_ids,
39        }
40    }
41}
42
43#[derive(Clone, Debug, Default, PartialEq)]
44pub struct BatteryPoolSnapshot(HashMap<InverterBatteryGroup, InverterBatteryGroupStatus>);
45
46impl BatteryPoolSnapshot {
47    pub fn groups(&self) -> &HashMap<InverterBatteryGroup, InverterBatteryGroupStatus> {
48        &self.0
49    }
50}
51
52/// A tracker that watches every inverter-battery group in the pool and emits
53/// a [`BatteryPoolSnapshot`] whenever any component's telemetry or health
54/// classification changes.
55#[derive(Clone)]
56pub(crate) struct BatteryPoolTelemetryTracker {
57    component_ids: BTreeSet<u64>,
58    component_pool_status_tx: CachingSender<BatteryPoolSnapshot>,
59    missing_data_tolerance: Duration,
60    healthy_state_codes: HashSet<ElectricalComponentStateCode>,
61    client: MicrogridClientHandle,
62    logical_meter: LogicalMeterHandle,
63}
64
65impl BatteryPoolTelemetryTracker {
66    pub(crate) fn new(
67        component_ids: BTreeSet<u64>,
68        missing_data_tolerance: Duration,
69        healthy_state_codes: HashSet<ElectricalComponentStateCode>,
70        client: MicrogridClientHandle,
71        logical_meter: LogicalMeterHandle,
72        component_pool_status_tx: CachingSender<BatteryPoolSnapshot>,
73    ) -> Self {
74        Self {
75            component_ids,
76            component_pool_status_tx,
77            missing_data_tolerance,
78            healthy_state_codes,
79            client,
80            logical_meter,
81        }
82    }
83
84    /// Walks the component graph to partition `component_ids` (battery IDs) into
85    /// inverter-battery groups, validating that the selection is complete: each
86    /// battery must reach only inverters whose other batteries are also in the
87    /// set. Returns an [`Error`] for a malformed or partial selection.
88    ///
89    /// An empty `component_ids` set is a valid (empty) pool: the loop visits no
90    /// batteries and yields no groups.
91    pub(crate) fn inverter_battery_groups(
92        graph: &ComponentGraph<ElectricalComponent, ElectricalComponentConnection>,
93        component_ids: &BTreeSet<u64>,
94    ) -> Result<Vec<InverterBatteryGroup>, Error> {
95        let mut unvisited_batteries = component_ids.clone();
96        let mut groups = Vec::new();
97
98        while let Some(battery_id) = unvisited_batteries.iter().next().cloned() {
99            let group_inverters = graph
100                .predecessors(battery_id)
101                .map_err(|e| {
102                    tracing::error!("Failed to query predecessors of battery {battery_id}: {e}");
103                    e
104                })?
105                .filter(|c| c.category() == crate::client::ElectricalComponentCategory::Inverter)
106                .map(|c| c.id)
107                .collect::<BTreeSet<_>>();
108
109            if group_inverters.is_empty() {
110                let e = format!("Battery {} is not connected to any inverters.", battery_id);
111                tracing::error!("{}", e);
112                return Err(Error::component_data_error(e));
113            }
114
115            let mut group_batteries = BTreeSet::new();
116            for inverter_id in &group_inverters {
117                let connected_batteries = graph
118                    .successors(*inverter_id)
119                    .map_err(|e| {
120                        tracing::error!(
121                            "Failed to query successors of inverter {inverter_id}: {e}"
122                        );
123                        e
124                    })?
125                    .map(|c| c.id)
126                    .collect::<BTreeSet<_>>();
127
128                group_batteries.extend(connected_batteries);
129            }
130
131            // Ensure that all group batteries are part of the request.
132            if !group_batteries.is_subset(component_ids) {
133                let e = format!(
134                    concat!(
135                        "Inverters {:?} are connected to batteries {:?} which are not all in ",
136                        "the requested component IDs {:?}"
137                    ),
138                    group_inverters, group_batteries, component_ids
139                );
140
141                tracing::error!("{}", e);
142                return Err(Error::component_data_error(e));
143            }
144
145            // Remove the group batteries from the unvisited set
146            unvisited_batteries.retain(|b| !group_batteries.contains(b));
147
148            // Ensure that group batteries are only connect to group inverters
149            for battery_id in &group_batteries {
150                let connected_inverters = graph
151                    .predecessors(*battery_id)
152                    .map_err(|e| {
153                        tracing::error!(
154                            "Failed to query predecessors of battery {battery_id}: {e}"
155                        );
156                        e
157                    })?
158                    .filter(|c| {
159                        c.category() == crate::client::ElectricalComponentCategory::Inverter
160                    })
161                    .map(|c| c.id)
162                    .collect::<BTreeSet<_>>();
163
164                if !connected_inverters.is_subset(&group_inverters) {
165                    let e = format!(
166                        "Battery {} is connected to inverters {:?} which are not all in the same group {:?}",
167                        battery_id, connected_inverters, group_inverters
168                    );
169                    tracing::error!("{}", e);
170                    return Err(Error::component_data_error(e));
171                }
172            }
173
174            groups.push(InverterBatteryGroup::new(group_inverters, group_batteries));
175        }
176
177        Ok(groups)
178    }
179
180    pub(crate) async fn run(self) {
181        // Errors are logged at source inside `inverter_battery_groups`.
182        let Ok(inverter_battery_group_ids) =
183            Self::inverter_battery_groups(self.logical_meter.graph(), &self.component_ids)
184        else {
185            // Construction (`BatteryPool::try_new`) already validated the
186            // topology, so this only fires on a transient graph-query failure.
187            // Return without publishing: dropping the sender closes the stream,
188            // which a subscriber can tell apart from a valid empty snapshot,
189            // instead of passing off a malformed pool as an empty one.
190            return;
191        };
192
193        let is_empty_pool = inverter_battery_group_ids.is_empty();
194
195        // Seed each group as all-unhealthy so the initial snapshot reflects the
196        // pool's real membership (every group present, unhealthy until its data
197        // arrives) rather than an empty map.
198        let mut snapshot = BatteryPoolSnapshot(
199            inverter_battery_group_ids
200                .iter()
201                .map(|group| {
202                    let mut inverters = ComponentHealthPartition::default();
203                    for &inverter_id in &group.inverter_ids {
204                        inverters.mark_unhealthy(inverter_id, None);
205                    }
206                    let mut batteries = ComponentHealthPartition::default();
207                    for &battery_id in &group.battery_ids {
208                        batteries.mark_unhealthy(battery_id, None);
209                    }
210                    (
211                        group.clone(),
212                        InverterBatteryGroupStatus {
213                            inverters,
214                            batteries,
215                        },
216                    )
217                })
218                .collect(),
219        );
220
221        // Publish the initial (seeded, all-unhealthy) snapshot before opening the
222        // group streams, so a fresh subscriber gets it (or its cached copy) at
223        // once. Ignore "no receivers" here — the tick loop below owns shutdown.
224        let _ = self.component_pool_status_tx.publish(snapshot.clone());
225
226        let (component_status_tx, mut component_status_rx) = tokio::sync::mpsc::channel(100);
227        for inverter_battery_group in inverter_battery_group_ids {
228            let tracker = InverterBatteryGroupTelemetryTracker::new(
229                inverter_battery_group,
230                self.missing_data_tolerance,
231                self.healthy_state_codes.clone(),
232                self.client.clone(),
233                component_status_tx.clone(),
234            );
235            // Spawn a task for each group telemetry tracker
236            tokio::spawn(tracker.run());
237        }
238
239        // Drop the original sender so the channel closes once every group tracker
240        // finishes, ending the loop below. An empty pool spawns no trackers, so
241        // keep the sender instead: `recv()` then parks, and the tick loop drives
242        // the (empty) snapshot and the receiver-count shutdown check — so the
243        // task stops when its consumers go, not before.
244        let _empty_pool_keepalive = if is_empty_pool {
245            Some(component_status_tx)
246        } else {
247            drop(component_status_tx);
248            None
249        };
250
251        let mut interval = tokio::time::interval(Duration::from_millis(200));
252
253        loop {
254            tokio::select! {
255                maybe_status = component_status_rx.recv() => {
256                    match maybe_status {
257                        Some((group_ids, status)) => {
258                            snapshot.0.insert(group_ids, status);
259                        }
260                        // Every group tracker has exited and dropped its sender,
261                        // so no further updates will ever arrive. The `_ =
262                        // interval.tick()` arm below is a catch-all that never
263                        // disables, so the `select!` `else` branch can never run;
264                        // break here instead.
265                        None => break,
266                    }
267                },
268                _ = interval.tick() => {
269                    // Publish only when the groups changed; either way, stop once
270                    // the last consumer has dropped.
271                    if !self.component_pool_status_tx.publish_if_changed(&snapshot) {
272                        break;
273                    }
274                },
275            }
276        }
277
278        // Reaching here means either every consumer dropped or every group
279        // tracker exited — a normal shutdown, not an error.
280        tracing::debug!(
281            "BatteryPoolTelemetryTracker (component IDs {:?}) stopped: all consumers or group trackers are gone.",
282            self.component_ids
283        );
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use std::collections::HashMap;
290
291    use super::BatteryPoolSnapshot;
292    use crate::client::proto::common::microgrid::electrical_components::ElectricalComponentStateCode;
293    use crate::client::test_utils::MockComponent;
294    use crate::microgrid::battery_pool::BatteryPool;
295    use crate::microgrid::telemetry_tracker::battery_pool_telemetry_tracker::InverterBatteryGroup;
296    use crate::microgrid::telemetry_tracker::inverter_battery_group_telemetry_tracker::InverterBatteryGroupStatus;
297    use crate::microgrid::test_utils::{handles, last_snapshot};
298
299    impl BatteryPoolSnapshot {
300        pub(crate) fn from_groups(
301            groups: HashMap<InverterBatteryGroup, InverterBatteryGroupStatus>,
302        ) -> Self {
303            Self(groups)
304        }
305    }
306    async fn new_pool(graph: MockComponent) -> BatteryPool {
307        let (client, lm) = handles(graph).await;
308        BatteryPool::try_new(None, client, lm).unwrap()
309    }
310
311    #[tokio::test(start_paused = true)]
312    async fn single_group_reaches_healthy_state() {
313        // grid → meter → battery_inverter(3) → battery(4)
314        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
315            MockComponent::meter(2).with_children(vec![
316                    MockComponent::battery_inverter(3)
317                        .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
318                        .with_children(vec![
319                            MockComponent::battery(4)
320                                .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
321                        ]),
322                ]),
323        ]))
324        .await;
325
326        let mut rx = pool.telemetry_snapshots();
327        let snap = last_snapshot(&mut rx, 10).await;
328
329        let groups = snap.groups();
330        assert_eq!(
331            groups.len(),
332            1,
333            "expected exactly one inverter-battery group"
334        );
335
336        let (group, status) = groups.iter().next().unwrap();
337        assert_eq!(group.inverter_ids, [3].into());
338        assert_eq!(group.battery_ids, [4].into());
339        assert!(status.inverters.healthy.contains_key(&3));
340        assert!(status.batteries.healthy.contains_key(&4));
341        assert!(status.inverters.unhealthy.is_empty());
342        assert!(status.batteries.unhealthy.is_empty());
343    }
344
345    #[tokio::test(start_paused = true)]
346    async fn two_disjoint_groups_both_appear_in_snapshot() {
347        // grid → meter → [battery_inverter(3)→battery(4), battery_inverter(5)→battery(6)]
348        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
349            MockComponent::meter(2).with_children(vec![
350                    MockComponent::battery_inverter(3)
351                        .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
352                        .with_children(vec![
353                            MockComponent::battery(4)
354                                .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
355                        ]),
356                    MockComponent::battery_inverter(5)
357                        .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
358                        .with_children(vec![
359                            MockComponent::battery(6)
360                                .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
361                        ]),
362                ]),
363        ]))
364        .await;
365
366        let mut rx = pool.telemetry_snapshots();
367        let snap = last_snapshot(&mut rx, 10).await;
368
369        let groups = snap.groups();
370        assert_eq!(groups.len(), 2);
371
372        let all_inverters: std::collections::BTreeSet<u64> = groups
373            .keys()
374            .flat_map(|g| g.inverter_ids.iter().copied())
375            .collect();
376        let all_batteries: std::collections::BTreeSet<u64> = groups
377            .keys()
378            .flat_map(|g| g.battery_ids.iter().copied())
379            .collect();
380        assert_eq!(all_inverters, [3, 5].into());
381        assert_eq!(all_batteries, [4, 6].into());
382
383        for status in groups.values() {
384            assert!(status.inverters.unhealthy.is_empty());
385            assert!(status.batteries.unhealthy.is_empty());
386        }
387    }
388
389    #[tokio::test(start_paused = true)]
390    async fn calling_telemetry_snapshots_twice_reuses_sender() {
391        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
392            MockComponent::meter(2).with_children(vec![
393                    MockComponent::battery_inverter(3)
394                        .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
395                        .with_children(vec![
396                            MockComponent::battery(4)
397                                .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
398                        ]),
399                ]),
400        ]))
401        .await;
402
403        let mut rx1 = pool.telemetry_snapshots();
404        let mut rx2 = pool.telemetry_snapshots();
405
406        // Advance so the tracker publishes at least one snapshot.
407        tokio::time::advance(std::time::Duration::from_millis(300)).await;
408
409        let snap1 = last_snapshot(&mut rx1, 0).await;
410        let snap2 = last_snapshot(&mut rx2, 0).await;
411        assert_eq!(
412            snap1, snap2,
413            "both subscriptions should observe the same snapshot"
414        );
415    }
416
417    #[tokio::test(start_paused = true)]
418    async fn components_become_unhealthy_when_data_stops() {
419        // Both components emit only a handful of samples and then go silent;
420        // the stream stays open so the client actor doesn't reconnect and
421        // resupply data.
422        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
423            MockComponent::meter(2).with_children(vec![
424                    MockComponent::battery_inverter(3)
425                        .with_power(vec![0.0, 0.0, 0.0])
426                        .with_silence_after_metrics()
427                        .with_children(vec![
428                            MockComponent::battery(4)
429                                .with_power(vec![0.0, 0.0, 0.0])
430                                .with_silence_after_metrics(),
431                        ]),
432                ]),
433        ]))
434        .await;
435
436        let mut rx = pool.telemetry_snapshots();
437
438        // First: drain past the healthy phase and confirm components reach a
439        // healthy state (3 samples over ~600ms).
440        let healthy = last_snapshot(&mut rx, 10).await;
441        let (_, status) = healthy.groups().iter().next().unwrap();
442        assert!(
443            status.inverters.healthy.contains_key(&3) && status.batteries.healthy.contains_key(&4),
444            "expected components to go healthy after initial samples, got {:?}",
445            status
446        );
447
448        // Now advance well past the 10s missing-data tolerance — the
449        // component telemetry trackers should fire their interval and
450        // reclassify both components as unhealthy.
451        tokio::time::advance(std::time::Duration::from_secs(15)).await;
452        let unhealthy = last_snapshot(&mut rx, 5).await;
453
454        let (_, status) = unhealthy.groups().iter().next().unwrap();
455        assert!(
456            status.inverters.healthy.is_empty(),
457            "inverter should be unhealthy after data stops, got healthy set {:?}",
458            status.inverters.healthy.keys()
459        );
460        assert!(
461            status.batteries.healthy.is_empty(),
462            "battery should be unhealthy after data stops, got healthy set {:?}",
463            status.batteries.healthy.keys()
464        );
465        assert!(status.inverters.unhealthy.contains_key(&3));
466        assert!(status.batteries.unhealthy.contains_key(&4));
467    }
468
469    #[tokio::test(start_paused = true)]
470    async fn component_with_bad_state_is_unhealthy() {
471        // Battery reports an Error state — it must land in the unhealthy
472        // set even though samples keep arriving.
473        let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
474            MockComponent::meter(2).with_children(vec![
475                    MockComponent::battery_inverter(3)
476                        .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
477                        .with_children(vec![
478                            MockComponent::battery(4)
479                                .with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
480                                .with_state(ElectricalComponentStateCode::Error),
481                        ]),
482                ]),
483        ]))
484        .await;
485
486        let mut rx = pool.telemetry_snapshots();
487        let snap = last_snapshot(&mut rx, 10).await;
488
489        let (_, status) = snap.groups().iter().next().unwrap();
490        assert!(
491            status.inverters.healthy.contains_key(&3),
492            "inverter with Ready state should be healthy"
493        );
494        assert!(
495            !status.batteries.healthy.contains_key(&4),
496            "battery with Error state should not be in healthy set"
497        );
498        assert!(
499            status.batteries.unhealthy.contains_key(&4),
500            "battery with Error state should be in unhealthy set, got {:?}",
501            status
502        );
503    }
504}