frequenz_microgrid/microgrid/
battery_pool.rs1use tokio::sync::broadcast;
7
8use std::collections::{BTreeSet, HashSet};
9use std::time::Duration;
10
11use crate::{
12 Bounds, Error, Formula, LogicalMeterHandle, MicrogridClientHandle,
13 client::{
14 ElectricalComponentCategory,
15 proto::common::microgrid::electrical_components::ElectricalComponentStateCode,
16 },
17 metric,
18 metric::Metric,
19 microgrid::{
20 caching_sender::{CachingSender, WeakCachingSender},
21 pool_bounds,
22 pool_bounds_tracker::PoolBoundsTracker,
23 pool_validation::validate_pool_ids,
24 telemetry_tracker::battery_pool_telemetry_tracker::{
25 BatteryPoolSnapshot, BatteryPoolTelemetryTracker,
26 },
27 },
28 quantity::Power,
29};
30
31pub struct BatteryPool {
33 component_ids: Option<BTreeSet<u64>>,
34 client: MicrogridClientHandle,
35 logical_meter: LogicalMeterHandle,
36 snapshot_tx: Option<WeakCachingSender<BatteryPoolSnapshot>>,
37 bounds_tx: Option<WeakCachingSender<Vec<Bounds<Power>>>>,
38}
39
40impl BatteryPool {
41 pub(crate) fn try_new(
44 component_ids: Option<BTreeSet<u64>>,
45 client: MicrogridClientHandle,
46 logical_meter: LogicalMeterHandle,
47 ) -> Result<Self, Error> {
48 let this = Self {
49 component_ids,
50 client,
51 logical_meter,
52 snapshot_tx: None,
53 bounds_tx: None,
54 };
55 validate_pool_ids(
56 &this.component_ids,
57 &this.get_all_battery_ids(),
58 "batteries",
59 )
60 .inspect_err(|e| tracing::error!("{e}"))?;
61 BatteryPoolTelemetryTracker::inverter_battery_groups(
66 this.logical_meter.graph(),
67 &this.get_battery_ids(),
68 )?;
69 Ok(this)
70 }
71
72 fn get_all_battery_ids(&self) -> BTreeSet<u64> {
73 self.logical_meter
74 .graph()
75 .components()
76 .filter(|c| c.category() == ElectricalComponentCategory::Battery)
77 .map(|c| c.id)
78 .collect()
79 }
80
81 pub(crate) fn get_battery_ids(&self) -> BTreeSet<u64> {
82 if let Some(ids) = &self.component_ids {
83 ids.clone()
84 } else {
85 self.get_all_battery_ids()
86 }
87 }
88
89 pub fn power(&mut self) -> Result<Formula<Power>, Error> {
91 self.logical_meter
92 .battery::<metric::AcPowerActive>(self.component_ids.clone())
93 }
94
95 pub fn power_bounds(&mut self) -> broadcast::Receiver<Vec<Bounds<Power>>> {
102 if let Some(tx) = self.bounds_tx.as_ref().and_then(WeakCachingSender::upgrade)
103 && tx.receiver_count() > 0
104 {
105 return tx.subscribe_with_current();
106 }
107 let snapshot_rx = self.telemetry_snapshots();
108 let tx = CachingSender::<Vec<Bounds<Power>>>::new();
109 let rx = tx.subscribe_with_current();
112 let tracker = PoolBoundsTracker::new(
113 snapshot_rx,
114 tx.clone(),
115 pool_bounds::compute_battery_pool_bounds::<metric::AcPowerActive, metric::DcPower>,
116 format!(
117 "{}/{}",
118 metric::AcPowerActive::str_name(),
119 metric::DcPower::str_name()
120 ),
121 );
122 tokio::spawn(tracker.run());
123 self.bounds_tx = Some(tx.downgrade());
124 rx
125 }
126
127 pub fn telemetry_snapshots(&mut self) -> broadcast::Receiver<BatteryPoolSnapshot> {
134 if let Some(tx) = self
135 .snapshot_tx
136 .as_ref()
137 .and_then(WeakCachingSender::upgrade)
138 && tx.receiver_count() > 0
139 {
140 return tx.subscribe_with_current();
141 }
142 let tx = CachingSender::<BatteryPoolSnapshot>::new();
143 let rx = tx.subscribe_with_current();
146 let tracker = BatteryPoolTelemetryTracker::new(
147 self.get_battery_ids(),
148 Duration::from_secs(10),
149 HashSet::from([
150 ElectricalComponentStateCode::Ready,
151 ElectricalComponentStateCode::Standby,
152 ElectricalComponentStateCode::Charging,
153 ElectricalComponentStateCode::Discharging,
154 ElectricalComponentStateCode::RelayClosed,
155 ]),
156 self.client.clone(),
157 self.logical_meter.clone(),
158 tx.clone(),
159 );
160 tokio::spawn(tracker.run());
161 self.snapshot_tx = Some(tx.downgrade());
162 rx
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::BatteryPool;
169 use crate::client::test_utils::MockComponent;
170 use crate::microgrid::test_utils::{handles, last_snapshot};
171
172 fn battery_less_graph() -> MockComponent {
174 MockComponent::grid(1).with_children(vec![MockComponent::meter(2)])
175 }
176
177 #[tokio::test]
178 async fn try_new_none_constructs_an_empty_pool_without_batteries() {
179 let (client, lm) = handles(battery_less_graph()).await;
180 let mut pool = BatteryPool::try_new(None, client, lm)
182 .expect("a battery-less microgrid should yield an empty pool");
183 pool.power().expect("empty pool power formula");
184 }
185
186 #[tokio::test(start_paused = true)]
187 async fn empty_pool_emits_empty_snapshot_and_bounds() {
188 let (client, lm) = handles(battery_less_graph()).await;
189 let mut pool = BatteryPool::try_new(None, client, lm).unwrap();
190
191 let mut snapshots = pool.telemetry_snapshots();
192 let mut bounds = pool.power_bounds();
193
194 let snapshot = last_snapshot(&mut snapshots, 5).await;
195 assert!(
196 snapshot.groups().is_empty(),
197 "empty pool snapshot should have no groups, got {snapshot:?}"
198 );
199
200 let bounds = last_snapshot(&mut bounds, 5).await;
201 assert!(
202 bounds.is_empty(),
203 "empty pool should have empty power bounds"
204 );
205 }
206
207 fn shared_inverter_graph() -> MockComponent {
209 MockComponent::grid(1).with_children(vec![MockComponent::meter(2).with_children(vec![
210 MockComponent::battery_inverter(3).with_children(vec![
211 MockComponent::battery(4),
212 MockComponent::battery(5),
213 ]),
214 ])])
215 }
216
217 #[tokio::test]
218 async fn try_new_rejects_partial_inverter_battery_group() {
219 let (client, lm) = handles(shared_inverter_graph()).await;
223 assert!(
224 BatteryPool::try_new(Some([4].into()), client, lm).is_err(),
225 "a partial inverter-battery group must be rejected"
226 );
227 }
228}