Skip to main content

frequenz_microgrid_component_graph/graph/
formulas.rs

1// License: MIT
2// Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3
4//! Methods for building formulas for various microgrid metrics.
5
6use std::collections::BTreeSet;
7
8use crate::ComponentGraph;
9use crate::Edge;
10use crate::Error;
11use crate::Node;
12use crate::component_category::CategoryPredicates;
13
14mod expr;
15mod fallback;
16mod formula;
17mod generators;
18mod traversal;
19
20use expr::Expr;
21pub use formula::Formula;
22
23/// Formulas for various microgrid metrics.
24impl<N, E> ComponentGraph<N, E>
25where
26    N: Node,
27    E: Edge,
28{
29    /// Returns the consumer formula for the graph.
30    pub fn consumer_formula(&self) -> Result<Formula, Error> {
31        generators::consumer::ConsumerFormulaBuilder::try_new(self)?.build()
32    }
33
34    /// Returns the grid formula for the graph.
35    pub fn grid_formula(&self) -> Result<Formula, Error> {
36        generators::grid::GridFormulaBuilder::try_new(self)?.build()
37    }
38
39    /// Returns the producer formula for the graph.
40    pub fn producer_formula(&self) -> Result<Formula, Error> {
41        generators::producer::ProducerFormulaBuilder::try_new(self)?.build()
42    }
43
44    /// Returns the battery formula with the given battery IDs.
45    ///
46    /// If `battery_ids` is `None`, the formula will contain all batteries in
47    /// the graph.
48    pub fn battery_formula(&self, battery_ids: Option<BTreeSet<u64>>) -> Result<Formula, Error> {
49        generators::battery::BatteryFormulaBuilder::try_new(self, battery_ids)?.build()
50    }
51
52    /// Returns the CHP formula for the graph.
53    pub fn chp_formula(&self, chp_ids: Option<BTreeSet<u64>>) -> Result<Formula, Error> {
54        generators::category::category_formula(
55            self,
56            chp_ids,
57            |node| node.is_chp(),
58            "a CHP",
59            self.config.prefer_meters_in_chp_formula(),
60        )
61    }
62
63    /// Returns the PV formula for the graph.
64    pub fn pv_formula(&self, pv_inverter_ids: Option<BTreeSet<u64>>) -> Result<Formula, Error> {
65        generators::category::category_formula(
66            self,
67            pv_inverter_ids,
68            |node| node.is_pv_inverter(),
69            "a PV inverter",
70            self.config.prefer_meters_in_pv_formula(),
71        )
72    }
73
74    /// Returns the wind_turbine formula for the graph.
75    pub fn wind_turbine_formula(
76        &self,
77        wind_turbine_ids: Option<BTreeSet<u64>>,
78    ) -> Result<Formula, Error> {
79        generators::category::category_formula(
80            self,
81            wind_turbine_ids,
82            |node| node.is_wind_turbine(),
83            "a wind turbine",
84            self.config.prefer_meters_in_wind_turbine_formula(),
85        )
86    }
87
88    /// Returns the EV charger formula for the graph.
89    pub fn ev_charger_formula(
90        &self,
91        ev_charger_ids: Option<BTreeSet<u64>>,
92    ) -> Result<Formula, Error> {
93        generators::category::category_formula(
94            self,
95            ev_charger_ids,
96            |node| node.is_ev_charger(),
97            "an EV charger",
98            self.config.prefer_meters_in_ev_charger_formula(),
99        )
100    }
101
102    /// Returns the formula for a specific component by its ID.
103    ///
104    /// A component that provides no telemetry has no reading to emit, so its
105    /// formula is `None`.
106    ///
107    /// Returns an error when `component_id` is not in the graph.
108    pub fn component_formula(&self, component_id: u64) -> Result<Formula, Error> {
109        if !self.component(component_id)?.provides_telemetry() {
110            return Ok(Expr::None.into());
111        }
112        Ok(Expr::component(component_id).into())
113    }
114
115    /// Returns the grid coalesce formula for the graph.
116    ///
117    /// This formula is used for non-aggregating metrics like AC voltage or
118    /// frequency.
119    ///
120    /// The formula is a `COALESCE` expression that includes all meters,
121    /// PV inverters, and battery inverters that are directly connected to the
122    /// grid.
123    ///
124    /// A component that provides no telemetry is skipped. When no component
125    /// provides telemetry, the formula is `None`.
126    pub fn grid_coalesce_formula(&self) -> Result<Formula, Error> {
127        generators::grid_coalesce::GridCoalesceFormulaBuilder::try_new(self)?.build()
128    }
129
130    /// Returns the battery AC coalesce formula for the given components.
131    ///
132    /// This formula is used for non-aggregating metrics like AC voltage or
133    /// frequency.
134    ///
135    /// The formula is a `COALESCE` expression that includes all the specified
136    /// battery meters and corresponding inverters.
137    ///
138    /// When the `battery_ids` parameter is `None`, it will include all the
139    /// battery meters and inverters in the graph.
140    ///
141    /// A component that provides no telemetry is skipped. When no component
142    /// provides telemetry, the formula is `None`.
143    pub fn battery_ac_coalesce_formula(
144        &self,
145        battery_ids: Option<BTreeSet<u64>>,
146    ) -> Result<Formula, Error> {
147        generators::battery_ac_coalesce::BatteryAcCoalesceFormulaBuilder::try_new(
148            self,
149            battery_ids,
150        )?
151        .build()
152    }
153
154    /// Returns the PV AC coalesce formula for the given components.
155    ///
156    /// This formula is used for non-aggregating metrics like AC voltage or
157    /// frequency.
158    ///
159    /// The formula is a `COALESCE` expression that includes all the specified
160    /// PV meters and corresponding inverters.
161    ///
162    /// When the `pv_inverter_ids` parameter is `None`, it will include all the
163    /// PV meters and inverters in the graph.
164    ///
165    /// A component that provides no telemetry is skipped. When no component
166    /// provides telemetry, the formula is `None`.
167    pub fn pv_ac_coalesce_formula(
168        &self,
169        pv_inverter_ids: Option<BTreeSet<u64>>,
170    ) -> Result<Formula, Error> {
171        generators::pv_ac_coalesce::PVAcCoalesceFormulaBuilder::try_new(self, pv_inverter_ids)?
172            .build()
173    }
174
175    /// Returns the AC coalesce formula for a specific component by its ID.
176    ///
177    /// A component that provides no telemetry has no reading to emit, so its
178    /// formula is `None`.
179    ///
180    /// Returns an error when `component_id` is not in the graph.
181    pub fn component_ac_coalesce_formula(&self, component_id: u64) -> Result<Formula, Error> {
182        if !self.component(component_id)?.provides_telemetry() {
183            return Ok(Expr::None.into());
184        }
185        Ok(Expr::component(component_id).into())
186    }
187
188    /// Returns the steam boiler formula for the graph.
189    pub fn steam_boiler_formula(
190        &self,
191        steam_boiler_ids: Option<BTreeSet<u64>>,
192    ) -> Result<Formula, Error> {
193        generators::category::category_formula(
194            self,
195            steam_boiler_ids,
196            |node| node.is_steam_boiler(),
197            "a steam boiler",
198            self.config.prefer_meters_in_steam_boiler_formula(),
199        )
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use crate::{
206        ComponentCategory, Error, InverterType, OperationalMode,
207        graph::test_utils::ComponentGraphBuilder,
208    };
209
210    /// `component_formula` and `component_ac_coalesce_formula` return the bare
211    /// reading of the requested component — no meter fallback, even when the
212    /// component sits behind a meter the category formulas would drill into.
213    #[test]
214    fn test_component_formula() -> Result<(), Error> {
215        let mut builder = ComponentGraphBuilder::new();
216        let grid = builder.grid();
217        let meter = builder.meter();
218        let inverter = builder.battery_inverter();
219        let battery = builder.battery();
220        builder.connect(grid, meter);
221        builder.connect(meter, inverter);
222        builder.connect(inverter, battery);
223
224        let graph = builder.build(None)?;
225        let inv = inverter.component_id();
226
227        assert_eq!(graph.component_formula(inv)?.to_string(), format!("#{inv}"));
228        assert_eq!(
229            graph.component_ac_coalesce_formula(inv)?.to_string(),
230            format!("#{inv}")
231        );
232        Ok(())
233    }
234
235    /// A component that provides no telemetry has no reading, so both formula
236    /// variants are `None`.
237    #[test]
238    fn test_component_formula_no_telemetry() -> Result<(), Error> {
239        let mut builder = ComponentGraphBuilder::new();
240        let grid = builder.grid();
241        let meter = builder.meter();
242        let inverter = builder.add_component_with_mode(
243            ComponentCategory::Inverter(InverterType::Battery),
244            OperationalMode::ControlOnly,
245        );
246        let battery = builder.battery();
247        builder.connect(grid, meter);
248        builder.connect(meter, inverter);
249        builder.connect(inverter, battery);
250
251        let graph = builder.build(None)?;
252        let inv = inverter.component_id();
253
254        assert_eq!(graph.component_formula(inv)?.to_string(), "None");
255        assert_eq!(
256            graph.component_ac_coalesce_formula(inv)?.to_string(),
257            "None"
258        );
259        Ok(())
260    }
261
262    /// The no-telemetry check has no category test, so a meter with no
263    /// telemetry gets `None` like any other component. The meter here is not
264    /// the grid meter, whose term is already null for its own reason.
265    ///
266    /// Topology (ids): `Grid:0 → Meter:1 → Meter:2 (no telemetry) → PV:3`.
267    #[test]
268    fn test_component_formula_no_telemetry_meter() -> Result<(), Error> {
269        let mut builder = ComponentGraphBuilder::new();
270        let grid = builder.grid();
271        let grid_meter = builder.meter();
272        let silent_meter =
273            builder.add_component_with_mode(ComponentCategory::Meter, OperationalMode::Inactive);
274        let pv = builder.solar_inverter();
275        builder.connect(grid, grid_meter);
276        builder.connect(grid_meter, silent_meter);
277        builder.connect(silent_meter, pv);
278
279        let graph = builder.build(None)?;
280        let id = silent_meter.component_id();
281
282        assert_eq!(graph.component_formula(id)?.to_string(), "None");
283        assert_eq!(graph.component_ac_coalesce_formula(id)?.to_string(), "None");
284        Ok(())
285    }
286
287    /// Both component formula variants check the given id and return an error
288    /// when it is not in the graph.
289    #[test]
290    fn test_component_formula_unknown_id() -> Result<(), Error> {
291        let mut builder = ComponentGraphBuilder::new();
292        let grid = builder.grid();
293        let meter = builder.meter();
294        builder.connect(grid, meter);
295        let graph = builder.build(None)?;
296
297        assert!(
298            graph
299                .component_formula(99)
300                .is_err_and(|e| e == Error::component_not_found("Component with id 99 not found."))
301        );
302        assert!(
303            graph
304                .component_ac_coalesce_formula(99)
305                .is_err_and(|e| e == Error::component_not_found("Component with id 99 not found."))
306        );
307        Ok(())
308    }
309}