frequenz_microgrid/microgrid/telemetry_tracker/
pv_pool_telemetry_tracker.rs1use 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#[derive(Clone, Debug, Default, PartialEq)]
30pub struct PvPoolSnapshot {
31 pub inverters: ComponentHealthPartition,
32}
33
34#[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 snapshot.inverters.mark_unhealthy(inverter_id, None);
68 }
69
70 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 tokio::spawn(async move {
102 tracker.run().await;
103 });
104 }
105
106 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 None => break,
136 }
137 },
138 _ = interval.tick() => {
139 if !self.component_pool_status_tx.publish_if_changed(&snapshot) {
143 break;
144 }
145 },
146 }
147 }
148
149 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 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 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 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 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 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 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 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}