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