use std::{
collections::{BTreeSet, HashSet},
time::Duration,
};
use tokio::sync::mpsc;
use crate::{
MicrogridClientHandle,
client::proto::common::microgrid::electrical_components::ElectricalComponentStateCode,
microgrid::caching_sender::CachingSender,
};
use super::component_partition::ComponentHealthPartition;
use super::component_telemetry_tracker::{ComponentHealthStatus, ComponentTelemetryTracker};
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PvPoolSnapshot {
pub inverters: ComponentHealthPartition,
}
#[derive(Clone)]
pub(crate) struct PvPoolTelemetryTracker {
component_ids: BTreeSet<u64>,
component_pool_status_tx: CachingSender<PvPoolSnapshot>,
missing_data_tolerance: Duration,
healthy_state_codes: HashSet<ElectricalComponentStateCode>,
client: MicrogridClientHandle,
}
impl PvPoolTelemetryTracker {
pub(crate) fn new(
component_ids: BTreeSet<u64>,
missing_data_tolerance: Duration,
healthy_state_codes: HashSet<ElectricalComponentStateCode>,
client: MicrogridClientHandle,
component_pool_status_tx: CachingSender<PvPoolSnapshot>,
) -> Self {
Self {
component_ids,
component_pool_status_tx,
missing_data_tolerance,
healthy_state_codes,
client,
}
}
pub(crate) async fn run(self) {
let mut snapshot = PvPoolSnapshot::default();
for &inverter_id in &self.component_ids {
snapshot.inverters.mark_unhealthy(inverter_id, None);
}
let _ = self.component_pool_status_tx.publish(snapshot.clone());
let (status_tx, mut status_rx) = mpsc::channel(100);
for &inverter_id in &self.component_ids {
let component_data_stream = match self
.client
.receive_electrical_component_telemetry_stream(inverter_id)
.await
{
Ok(stream) => stream,
Err(e) => {
tracing::error!(
"Internal error opening telemetry stream for inverter {inverter_id}: {e}; PV pool telemetry tracker aborting.",
);
return;
}
};
let tracker = ComponentTelemetryTracker::new(
inverter_id,
self.missing_data_tolerance,
self.healthy_state_codes.clone(),
component_data_stream,
status_tx.clone(),
);
tokio::spawn(async move {
tracker.run().await;
});
}
let _empty_pool_keepalive = if self.component_ids.is_empty() {
Some(status_tx)
} else {
drop(status_tx);
None
};
let mut interval = tokio::time::interval(Duration::from_millis(200));
loop {
tokio::select! {
maybe_status = status_rx.recv() => {
match maybe_status {
Some(ComponentHealthStatus::Healthy(id, data)) => {
snapshot.inverters.mark_healthy(id, data);
}
Some(ComponentHealthStatus::Unhealthy(id, data)) => {
snapshot.inverters.mark_unhealthy(id, data);
}
None => break,
}
},
_ = interval.tick() => {
if !self.component_pool_status_tx.publish_if_changed(&snapshot) {
break;
}
},
}
}
tracing::debug!(
"PvPoolTelemetryTracker (component IDs {:?}) stopped: all consumers or component trackers are gone.",
self.component_ids
);
}
}
#[cfg(test)]
mod tests {
use crate::client::proto::common::microgrid::electrical_components::ElectricalComponentStateCode;
use crate::client::test_utils::MockComponent;
use crate::microgrid::pv_pool::PvPool;
use crate::microgrid::test_utils::{handles, last_snapshot};
async fn new_pool(graph: MockComponent) -> PvPool {
let (client, lm) = handles(graph).await;
PvPool::try_new(None, client, lm).unwrap()
}
#[tokio::test(start_paused = true)]
async fn single_inverter_reaches_healthy_state() {
let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
MockComponent::meter(2).with_children(vec![
MockComponent::pv_inverter(3).with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
]),
]))
.await;
let mut rx = pool.telemetry_snapshots();
let snap = last_snapshot(&mut rx, 10).await;
assert!(snap.inverters.healthy.contains_key(&3));
assert!(snap.inverters.unhealthy.is_empty());
}
#[tokio::test(start_paused = true)]
async fn two_inverters_both_appear_in_snapshot() {
let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
MockComponent::meter(2).with_children(vec![
MockComponent::pv_inverter(3).with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
MockComponent::pv_inverter(4).with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
]),
]))
.await;
let mut rx = pool.telemetry_snapshots();
let snap = last_snapshot(&mut rx, 10).await;
assert!(snap.inverters.healthy.contains_key(&3));
assert!(snap.inverters.healthy.contains_key(&4));
assert!(snap.inverters.unhealthy.is_empty());
}
#[tokio::test(start_paused = true)]
async fn calling_telemetry_snapshots_twice_reuses_sender() {
let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
MockComponent::meter(2).with_children(vec![
MockComponent::pv_inverter(3).with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
]),
]))
.await;
let mut rx1 = pool.telemetry_snapshots();
let mut rx2 = pool.telemetry_snapshots();
tokio::time::advance(std::time::Duration::from_millis(300)).await;
let snap1 = last_snapshot(&mut rx1, 0).await;
let snap2 = last_snapshot(&mut rx2, 0).await;
assert_eq!(
snap1, snap2,
"both subscriptions should observe the same snapshot"
);
}
#[tokio::test(start_paused = true)]
async fn inverter_becomes_unhealthy_when_data_stops() {
let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
MockComponent::meter(2).with_children(vec![
MockComponent::pv_inverter(3)
.with_power(vec![0.0, 0.0, 0.0])
.with_silence_after_metrics(),
]),
]))
.await;
let mut rx = pool.telemetry_snapshots();
let healthy = last_snapshot(&mut rx, 10).await;
assert!(
healthy.inverters.healthy.contains_key(&3),
"expected inverter to go healthy after initial samples, got {:?}",
healthy
);
tokio::time::advance(std::time::Duration::from_secs(15)).await;
let unhealthy = last_snapshot(&mut rx, 5).await;
assert!(
unhealthy.inverters.healthy.is_empty(),
"inverter should be unhealthy after data stops, got healthy set {:?}",
unhealthy.inverters.healthy.keys()
);
assert!(unhealthy.inverters.unhealthy.contains_key(&3));
}
#[tokio::test(start_paused = true)]
async fn inverter_with_bad_state_is_unhealthy() {
let mut pool = new_pool(MockComponent::grid(1).with_children(vec![
MockComponent::meter(2).with_children(vec![
MockComponent::pv_inverter(3)
.with_power(vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
.with_state(ElectricalComponentStateCode::Error),
]),
]))
.await;
let mut rx = pool.telemetry_snapshots();
let snap = last_snapshot(&mut rx, 10).await;
assert!(
!snap.inverters.healthy.contains_key(&3),
"inverter with Error state should not be in healthy set"
);
assert!(
snap.inverters.unhealthy.contains_key(&3),
"inverter with Error state should be in unhealthy set, got {:?}",
snap
);
}
}