Skip to main content

frequenz_microgrid/microgrid/telemetry_tracker/
inverter_battery_group_telemetry_tracker.rs

1// License: MIT
2// Copyright © 2026 Frequenz Energy-as-a-Service GmbH
3
4//! A telemetry tracker for an inverter-battery group in the microgrid, which
5//! consists of a set of inverters and their associated batteries, connected
6//! in MxN configuration. Emits snapshots that partition the group's
7//! components into healthy and unhealthy sets, each annotated with the
8//! latest telemetry sample.
9
10use std::{collections::HashSet, time::Duration};
11
12use tokio::select;
13
14use crate::{
15    MicrogridClientHandle,
16    client::proto::common::microgrid::electrical_components::ElectricalComponentStateCode,
17    microgrid::telemetry_tracker::battery_pool_telemetry_tracker::InverterBatteryGroup,
18};
19
20use super::component_partition::ComponentHealthPartition;
21use super::component_telemetry_tracker::{ComponentHealthStatus, ComponentTelemetryTracker};
22
23/// A telemetry tracker for an inverter-battery group, which consists of a set
24/// of inverters and their associated batteries, connected in MxN
25/// configuration.
26///
27/// On every change, the tracker emits an [`InverterBatteryGroupStatus`] which
28/// partitions the group's components into healthy and unhealthy sets and
29/// carries the latest [`ElectricalComponentTelemetry`] sample seen for each
30/// component. Downstream consumers (e.g. the bounds tracker) can therefore
31/// read both the health state and the most recent metric samples from a
32/// single subscription without re-subscribing to the telemetry streams.
33#[derive(Clone)]
34pub(crate) struct InverterBatteryGroupTelemetryTracker {
35    inverter_battery_group: InverterBatteryGroup,
36    status_tx: tokio::sync::mpsc::Sender<(InverterBatteryGroup, InverterBatteryGroupStatus)>,
37    missing_data_tolerance: Duration,
38    healthy_state_codes: HashSet<ElectricalComponentStateCode>,
39    client: MicrogridClientHandle,
40}
41
42/// A snapshot of an inverter-battery group's components, partitioned by health
43/// status and annotated with the latest telemetry sample for each component
44/// (see [`ComponentHealthPartition`]).
45#[derive(Clone, Debug, Default, PartialEq)]
46pub struct InverterBatteryGroupStatus {
47    pub inverters: ComponentHealthPartition,
48    pub batteries: ComponentHealthPartition,
49}
50
51impl InverterBatteryGroupTelemetryTracker {
52    pub(crate) fn new(
53        inverter_battery_group: InverterBatteryGroup,
54        missing_data_tolerance: Duration,
55        healthy_state_codes: HashSet<ElectricalComponentStateCode>,
56        client: MicrogridClientHandle,
57        status_tx: tokio::sync::mpsc::Sender<(InverterBatteryGroup, InverterBatteryGroupStatus)>,
58    ) -> Self {
59        Self {
60            inverter_battery_group,
61            status_tx,
62            missing_data_tolerance,
63            healthy_state_codes,
64            client,
65        }
66    }
67
68    pub async fn run(self) {
69        let mut inverters = ComponentHealthPartition::default();
70        let mut batteries = ComponentHealthPartition::default();
71
72        let (inverter_status_tx, mut inverter_status_rx) = tokio::sync::mpsc::channel(100);
73
74        for &inverter_id in &self.inverter_battery_group.inverter_ids {
75            let component_data_stream = match self
76                .client
77                .receive_electrical_component_telemetry_stream(inverter_id)
78                .await
79            {
80                Ok(stream) => stream,
81                Err(e) => {
82                    tracing::error!(
83                        "Internal error opening telemetry stream for inverter {inverter_id}: {e}; inverter-battery group tracker aborting.",
84                    );
85                    return;
86                }
87            };
88            let tracker = ComponentTelemetryTracker::new(
89                inverter_id,
90                self.missing_data_tolerance,
91                self.healthy_state_codes.clone(),
92                component_data_stream,
93                inverter_status_tx.clone(),
94            );
95            // Spawn a task for each component telemetry tracker
96            tokio::spawn(async move {
97                tracker.run().await;
98            });
99            // Initially mark the component as unhealthy until we see data.
100            inverters.mark_unhealthy(inverter_id, None);
101        }
102
103        let (battery_status_tx, mut battery_status_rx) = tokio::sync::mpsc::channel(100);
104
105        for &battery_id in &self.inverter_battery_group.battery_ids {
106            let component_data_stream = match self
107                .client
108                .receive_electrical_component_telemetry_stream(battery_id)
109                .await
110            {
111                Ok(stream) => stream,
112                Err(e) => {
113                    tracing::error!(
114                        "Internal error opening telemetry stream for battery {battery_id}: {e}; inverter-battery group tracker aborting.",
115                    );
116                    return;
117                }
118            };
119            let tracker = ComponentTelemetryTracker::new(
120                battery_id,
121                self.missing_data_tolerance,
122                self.healthy_state_codes.clone(),
123                component_data_stream,
124                battery_status_tx.clone(),
125            );
126            // Spawn a task for each component telemetry tracker
127            tokio::spawn(async move {
128                tracker.run().await;
129            });
130            // Initially mark the component as unhealthy until we see data.
131            batteries.mark_unhealthy(battery_id, None);
132        }
133
134        // Drop the original senders in the main task to allow the component
135        // trackers to close the channels when they finish, which will signal
136        // the main loop to stop.
137        drop(inverter_status_tx);
138        drop(battery_status_tx);
139
140        loop {
141            select! {
142                inverter_status = inverter_status_rx.recv() => {
143                    let Some(inverter_status) = inverter_status else {
144                        // Every inverter component tracker has exited and dropped
145                        // its sender — a normal shutdown, not an error.
146                        tracing::debug!(
147                            "Inverter-battery group tracker (inverters {:?}) stopping: all inverter component trackers have exited.",
148                            self.inverter_battery_group.inverter_ids
149                        );
150                        return;
151                    };
152                    match inverter_status {
153                        ComponentHealthStatus::Healthy(component_id, data) => {
154                            inverters.mark_healthy(component_id, data);
155                        }
156                        ComponentHealthStatus::Unhealthy(component_id, data) => {
157                            inverters.mark_unhealthy(component_id, data);
158                        }
159                    }
160                },
161                battery_status = battery_status_rx.recv() => {
162                    let Some(battery_status) = battery_status else {
163                        // Every battery component tracker has exited and dropped
164                        // its sender — a normal shutdown, not an error.
165                        tracing::debug!(
166                            "Inverter-battery group tracker (batteries {:?}) stopping: all battery component trackers have exited.",
167                            self.inverter_battery_group.battery_ids
168                        );
169                        return;
170                    };
171                    match battery_status {
172                        ComponentHealthStatus::Healthy(component_id, data) => {
173                            batteries.mark_healthy(component_id, data);
174                        }
175                        ComponentHealthStatus::Unhealthy(component_id, data) => {
176                            batteries.mark_unhealthy(component_id, data);
177                        }
178                    }
179                }
180            }
181            if self
182                .status_tx
183                .send((
184                    self.inverter_battery_group.clone(),
185                    InverterBatteryGroupStatus {
186                        inverters: inverters.clone(),
187                        batteries: batteries.clone(),
188                    },
189                ))
190                .await
191                .is_err()
192            {
193                // The pool tracker dropped its receiver — a normal shutdown.
194                tracing::debug!(
195                    "Inverter-battery group tracker {:?} stopping: the pool tracker dropped its receiver.",
196                    self.inverter_battery_group
197                );
198                return;
199            }
200        }
201    }
202}