use tokio::sync::broadcast;
use std::collections::{BTreeSet, HashSet};
use std::time::Duration;
use crate::{
Bounds, Error, Formula, LogicalMeterHandle, MicrogridClientHandle,
client::proto::common::microgrid::electrical_components::ElectricalComponentStateCode,
metric,
metric::Metric,
microgrid::{
caching_sender::{CachingSender, WeakCachingSender},
pool_bounds,
pool_bounds_tracker::PoolBoundsTracker,
pool_validation::validate_pool_ids,
telemetry_tracker::pv_pool_telemetry_tracker::{PvPoolSnapshot, PvPoolTelemetryTracker},
},
quantity::Power,
};
pub struct PvPool {
component_ids: Option<BTreeSet<u64>>,
client: MicrogridClientHandle,
logical_meter: LogicalMeterHandle,
snapshot_tx: Option<WeakCachingSender<PvPoolSnapshot>>,
bounds_tx: Option<WeakCachingSender<Vec<Bounds<Power>>>>,
}
impl PvPool {
pub(crate) fn try_new(
component_ids: Option<BTreeSet<u64>>,
client: MicrogridClientHandle,
logical_meter: LogicalMeterHandle,
) -> Result<Self, Error> {
let this = Self {
component_ids,
client,
logical_meter,
snapshot_tx: None,
bounds_tx: None,
};
validate_pool_ids(
&this.component_ids,
&this.get_all_pv_inverter_ids(),
"PV inverters",
)
.inspect_err(|e| tracing::error!("{e}"))?;
Ok(this)
}
fn get_all_pv_inverter_ids(&self) -> BTreeSet<u64> {
self.logical_meter
.graph()
.components()
.filter(|c| c.is_pv_inverter())
.map(|c| c.id)
.collect()
}
pub(crate) fn get_pv_inverter_ids(&self) -> BTreeSet<u64> {
if let Some(ids) = &self.component_ids {
ids.clone()
} else {
self.get_all_pv_inverter_ids()
}
}
pub fn power(&mut self) -> Result<Formula<Power>, Error> {
self.logical_meter
.pv::<metric::AcPowerActive>(self.component_ids.clone())
}
pub fn power_bounds(&mut self) -> broadcast::Receiver<Vec<Bounds<Power>>> {
if let Some(tx) = self.bounds_tx.as_ref().and_then(WeakCachingSender::upgrade)
&& tx.receiver_count() > 0
{
return tx.subscribe_with_current();
}
let snapshot_rx = self.telemetry_snapshots();
let tx = CachingSender::<Vec<Bounds<Power>>>::new();
let rx = tx.subscribe_with_current();
let tracker = PoolBoundsTracker::new(
snapshot_rx,
tx.clone(),
pool_bounds::compute_pv_pool_bounds::<metric::AcPowerActive>,
format!("{} PV", metric::AcPowerActive::str_name()),
);
tokio::spawn(tracker.run());
self.bounds_tx = Some(tx.downgrade());
rx
}
pub fn telemetry_snapshots(&mut self) -> broadcast::Receiver<PvPoolSnapshot> {
if let Some(tx) = self
.snapshot_tx
.as_ref()
.and_then(WeakCachingSender::upgrade)
&& tx.receiver_count() > 0
{
return tx.subscribe_with_current();
}
let tx = CachingSender::<PvPoolSnapshot>::new();
let rx = tx.subscribe_with_current();
let tracker = PvPoolTelemetryTracker::new(
self.get_pv_inverter_ids(),
Duration::from_secs(10),
HashSet::from([
ElectricalComponentStateCode::Ready,
ElectricalComponentStateCode::Standby,
ElectricalComponentStateCode::Discharging,
]),
self.client.clone(),
tx.clone(),
);
tokio::spawn(tracker.run());
self.snapshot_tx = Some(tx.downgrade());
rx
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::PvPool;
use crate::client::test_utils::MockComponent;
use crate::microgrid::test_utils::{handles, last_snapshot};
fn graph() -> MockComponent {
MockComponent::grid(1).with_children(vec![MockComponent::meter(2).with_children(vec![
MockComponent::meter(3).with_children(vec![
MockComponent::pv_inverter(4),
MockComponent::pv_inverter(5),
]),
MockComponent::meter(6).with_children(vec![
MockComponent::battery_inverter(7).with_children(vec![MockComponent::battery(8)]),
]),
])])
}
#[tokio::test]
async fn try_new_accepts_empty_component_ids() {
let (client, lm) = handles(graph()).await;
let mut pool = PvPool::try_new(Some(BTreeSet::new()), client, lm)
.expect("an empty component_ids set should yield an empty pool");
pool.power().expect("empty pool power formula");
}
#[tokio::test(start_paused = true)]
async fn empty_pool_emits_empty_snapshot_and_bounds() {
let (client, lm) =
handles(MockComponent::grid(1).with_children(vec![MockComponent::meter(2)])).await;
let mut pool = PvPool::try_new(None, client, lm).unwrap();
let mut snapshots = pool.telemetry_snapshots();
let mut bounds = pool.power_bounds();
let snapshot = last_snapshot(&mut snapshots, 5).await;
assert!(
snapshot.inverters.healthy.is_empty() && snapshot.inverters.unhealthy.is_empty(),
"empty pool snapshot should have no inverters, got {snapshot:?}"
);
let bounds = last_snapshot(&mut bounds, 5).await;
assert!(
bounds.is_empty(),
"empty pool should have empty power bounds"
);
}
#[tokio::test(start_paused = true)]
async fn late_subscriber_sees_latest_snapshot() {
let (client, lm) = handles(graph()).await;
let mut pool = PvPool::try_new(None, client, lm).unwrap();
let mut early = pool.telemetry_snapshots();
let early_snap = last_snapshot(&mut early, 10).await;
assert_eq!(
early_snap.inverters.unhealthy.len(),
2,
"precondition: both inverters tracked"
);
let mut late = pool.telemetry_snapshots();
let late_snap = late
.try_recv()
.expect("late subscriber should be sent the cached snapshot at once");
assert_eq!(
late_snap, early_snap,
"late subscriber should see the latest snapshot, not an empty stream"
);
}
#[tokio::test(start_paused = true)]
async fn resubscribing_after_teardown_yields_a_current_valued_stream() {
let (client, lm) = handles(graph()).await;
let mut pool = PvPool::try_new(None, client, lm).unwrap();
let mut rx = pool.telemetry_snapshots();
assert_eq!(
last_snapshot(&mut rx, 10).await.inverters.unhealthy.len(),
2
);
drop(rx);
tokio::time::advance(std::time::Duration::from_secs(1)).await;
let mut rx = pool.telemetry_snapshots();
assert_eq!(
last_snapshot(&mut rx, 10).await.inverters.unhealthy.len(),
2,
"resubscribed stream should yield the pool's current snapshot"
);
}
#[tokio::test(start_paused = true)]
async fn calling_power_bounds_twice_reuses_the_tracker() {
let (client, lm) = handles(graph()).await;
let mut pool = PvPool::try_new(None, client, lm).unwrap();
let mut rx1 = pool.power_bounds();
let bounds1 = last_snapshot(&mut rx1, 10).await;
let mut rx2 = pool.power_bounds();
let bounds2 = rx2
.try_recv()
.expect("reused tracker should re-send its cached bounds immediately");
assert_eq!(bounds1, bounds2, "reused tracker shares the same bounds");
}
#[tokio::test]
async fn try_new_rejects_non_pv_component_ids() {
let (client, lm) = handles(graph()).await;
let err = PvPool::try_new(Some([4, 7, 8].into()), client, lm)
.err()
.expect("non-PV component_ids should be rejected");
assert!(
err.to_string().contains("must be PV inverters"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn power_formula_for_explicit_pv_inverters() {
let (client, lm) = handles(graph()).await;
let mut pool = PvPool::try_new(Some([4, 5].into()), client, lm).unwrap();
let formula = pool.power().unwrap();
assert_eq!(
formula.to_string(),
"METRIC_AC_POWER_ACTIVE::(COALESCE(#5 + #4, #3, COALESCE(#5, 0.0) + COALESCE(#4, 0.0)))"
);
}
#[tokio::test]
async fn power_formula_for_all_pv_inverters() {
let (client, lm) = handles(graph()).await;
let mut pool = PvPool::try_new(None, client, lm).unwrap();
let formula = pool.power().unwrap();
assert_eq!(
formula.to_string(),
"METRIC_AC_POWER_ACTIVE::(COALESCE(#5 + #4, #3, COALESCE(#5, 0.0) + COALESCE(#4, 0.0)))"
);
}
}