1use 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#[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#[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 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 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 unvisited_batteries.retain(|b| !group_batteries.contains(b));
147
148 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 let Ok(inverter_battery_group_ids) =
183 Self::inverter_battery_groups(self.logical_meter.graph(), &self.component_ids)
184 else {
185 return;
191 };
192
193 let is_empty_pool = inverter_battery_group_ids.is_empty();
194
195 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 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 tokio::spawn(tracker.run());
237 }
238
239 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 None => break,
266 }
267 },
268 _ = interval.tick() => {
269 if !self.component_pool_status_tx.publish_if_changed(&snapshot) {
272 break;
273 }
274 },
275 }
276 }
277
278 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 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 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 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 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 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 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 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}