use crate::component_category::CategoryPredicates;
use crate::{ComponentGraph, ComponentGraphConfig, Edge, Error, Node};
use std::collections::{BTreeMap, BTreeSet};
use super::expr::Expr;
#[derive(Clone, Copy)]
pub(crate) enum SourcePreference {
ComponentsFirst,
MetersFirst,
MetersFirstWithChains,
}
impl SourcePreference {
pub(crate) fn prefer_meters(prefer_meters: bool) -> Self {
if prefer_meters {
SourcePreference::MetersFirst
} else {
SourcePreference::ComponentsFirst
}
}
fn meters_first(self) -> bool {
matches!(
self,
SourcePreference::MetersFirst | SourcePreference::MetersFirstWithChains
)
}
fn allows_meter_chains(self) -> bool {
matches!(self, SourcePreference::MetersFirstWithChains)
}
}
pub(crate) fn aggregate<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
targets: BTreeSet<u64>,
policy: SourcePreference,
) -> Result<Expr, Error> {
sum(aggregate_terms(graph, targets, policy)?)
.ok_or(Error::internal("No components to generate formula."))
}
pub(crate) fn aggregate_terms<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
targets: BTreeSet<u64>,
policy: SourcePreference,
) -> Result<Vec<Expr>, Error> {
if graph.config.disable_fallback_components {
Ok(targets.into_iter().map(Expr::component).collect())
} else {
measurement_points(graph, &targets)?
.into_iter()
.map(|point| match point {
Measurement::Single(id) => measure(graph, id, policy),
Measurement::Diamond { components, meters } => {
diamond_term(&components, &meters, policy)
}
Measurement::Subtraction {
parent_meters,
subtracted,
components,
} => subtraction_term(&parent_meters, &subtracted, &components, policy),
})
.collect()
}
}
#[derive(Debug, PartialEq)]
enum Measurement {
Single(u64),
Diamond {
components: Vec<u64>,
meters: Vec<u64>,
},
Subtraction {
parent_meters: Vec<u64>,
subtracted: Vec<u64>,
components: Vec<u64>,
},
}
fn drop_subsumed(points: &mut Vec<Measurement>, subsumed: &[u64]) {
points
.retain(|point| !matches!(point, Measurement::Single(single) if subsumed.contains(single)));
}
fn measurement_points<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
targets: &BTreeSet<u64>,
) -> Result<Vec<Measurement>, Error> {
let mut remaining = targets.clone();
let mut points = Vec::new();
let mut seen = BTreeSet::new();
let mut subtractions = BTreeMap::new();
while let Some(id) = remaining.pop_first() {
if let Some(substitution) = meter_substitution(graph, id, targets)? {
for &sibling in &substitution.siblings {
remaining.remove(&sibling);
}
drop_subsumed(&mut points, &substitution.siblings);
if substitution.meters.len() > 1 {
seen.extend(&substitution.meters);
points.push(Measurement::Diamond {
components: group_components(id, substitution.siblings),
meters: substitution.meters,
});
} else {
for meter in substitution.meters {
if seen.insert(meter) {
points.push(Measurement::Single(meter));
}
}
}
} else {
match meter_subtraction(graph, id, targets, &mut subtractions)? {
Some(sub) if sub.parent_meters.iter().all(|meter| !seen.contains(meter)) => {
for &component in &sub.components {
remaining.remove(&component);
}
drop_subsumed(&mut points, &sub.components);
seen.extend(&sub.parent_meters);
points.push(Measurement::Subtraction {
parent_meters: sub.parent_meters,
subtracted: sub.subtracted,
components: sub.components,
});
}
_ => {
if seen.insert(id) {
points.push(Measurement::Single(id));
}
}
}
}
}
Ok(points)
}
fn group_components(seed: u64, siblings: Vec<u64>) -> Vec<u64> {
let mut components: Vec<u64> = std::iter::once(seed).chain(siblings).collect();
components.sort_unstable();
components
}
struct Substitution {
meters: Vec<u64>,
siblings: Vec<u64>,
}
fn meter_substitution<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
targets: &BTreeSet<u64>,
) -> Result<Option<Substitution>, Error> {
let Some(meters) = parent_meters(graph, id)? else {
return Ok(None);
};
let siblings: Vec<u64> = graph
.siblings_from_predecessors(id)?
.map(|sibling| sibling.component_id())
.collect();
if !siblings.iter().all(|sibling| targets.contains(sibling)) {
return Ok(None);
}
if siblings.iter().any(|sibling| meters.contains(sibling)) {
return Ok(None);
}
if !group_reached_only_through(graph, id, &siblings, &meters)? {
return Ok(None);
}
Ok(Some(Substitution {
meters: meters.into_iter().collect(),
siblings,
}))
}
fn parent_meters<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
) -> Result<Option<BTreeSet<u64>>, Error> {
if !is_measurable_component(graph.component(id)?, &graph.config) {
return Ok(None);
}
let meters: BTreeSet<u64> = graph
.predecessors(id)?
.filter(|predecessor| predecessor.is_meter())
.map(|predecessor| predecessor.component_id())
.collect();
if meters.is_empty() {
return Ok(None);
}
for &meter in &meters {
if is_grid_meter(graph, graph.component(meter)?)? {
return Ok(None);
}
}
Ok(Some(meters))
}
fn reached_only_through<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
meters: &BTreeSet<u64>,
) -> Result<bool, Error> {
reached_only_through_inner(graph, id, meters, &mut BTreeSet::new())
}
fn reached_only_through_inner<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
meters: &BTreeSet<u64>,
checked: &mut BTreeSet<u64>,
) -> Result<bool, Error> {
for predecessor in graph.predecessors(id)? {
let predecessor_id = predecessor.component_id();
if meters.contains(&predecessor_id) || !checked.insert(predecessor_id) {
continue;
}
if !predecessor.is_meter()
|| !reached_only_through_inner(graph, predecessor_id, meters, checked)?
{
return Ok(false);
}
}
Ok(true)
}
fn group_reached_only_through<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
siblings: &[u64],
meters: &BTreeSet<u64>,
) -> Result<bool, Error> {
for &component in std::iter::once(&id).chain(siblings) {
if !reached_only_through(graph, component, meters)? {
return Ok(false);
}
}
Ok(true)
}
fn reaches_any_below<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
set: &BTreeSet<u64>,
) -> Result<bool, Error> {
graph.reaches_any(
id,
|node| node.component_id() != id && set.contains(&node.component_id()),
petgraph::Direction::Outgoing,
)
}
#[derive(Clone)]
struct Subtraction {
parent_meters: Vec<u64>,
subtracted: Vec<u64>,
components: Vec<u64>,
}
fn meter_subtraction<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
targets: &BTreeSet<u64>,
cache: &mut BTreeMap<BTreeSet<u64>, Option<Subtraction>>,
) -> Result<Option<Subtraction>, Error> {
let Some(meters) = parent_meters(graph, id)? else {
return Ok(None);
};
if let Some(subtraction) = cache.get(&meters) {
return Ok(subtraction.clone());
}
let subtraction = subtraction_for_parents(graph, id, targets, &meters)?;
cache.insert(meters, subtraction.clone());
Ok(subtraction)
}
fn subtraction_for_parents<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
targets: &BTreeSet<u64>,
meters: &BTreeSet<u64>,
) -> Result<Option<Subtraction>, Error> {
let mut siblings = Vec::new();
let mut minus = Vec::new();
for sibling in graph.siblings_from_predecessors(id)? {
let sibling_id = sibling.component_id();
if targets.contains(&sibling_id) {
if sibling.is_meter() {
return Ok(None);
}
siblings.push(sibling_id);
} else if sibling.is_meter() || is_measurable_component(sibling, &graph.config) {
minus.push(sibling_id);
} else {
return Ok(None);
}
}
if minus.is_empty() {
return Ok(None);
}
for &subtracted in &minus {
if !reached_only_through(graph, subtracted, meters)? {
return Ok(None);
}
if reaches_any_below(graph, subtracted, targets)? {
return Ok(None);
}
}
let minus_set: BTreeSet<u64> = minus.iter().copied().collect();
let mut covered = Vec::new();
for &subtracted in &minus {
if !reaches_any_below(graph, subtracted, &minus_set)? {
continue;
}
if graph.has_successors(subtracted)?
&& graph
.successors(subtracted)?
.all(|child| minus_set.contains(&child.component_id()))
{
covered.push(subtracted);
} else {
return Ok(None);
}
}
minus.retain(|subtracted| !covered.contains(subtracted));
if !group_reached_only_through(graph, id, &siblings, meters)? {
return Ok(None);
}
minus.sort_unstable();
Ok(Some(Subtraction {
parent_meters: meters.iter().copied().collect(),
subtracted: minus,
components: group_components(id, siblings),
}))
}
fn measure<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
policy: SourcePreference,
) -> Result<Expr, Error> {
let own = Expr::component(id);
let component = graph.component(id)?;
if !component.is_meter() {
return Ok(own.coalesce(Expr::number(0.0)));
}
let children: Vec<&N> = graph.successors(id)?.collect();
let child_ids: BTreeSet<u64> = children.iter().map(|c| c.component_id()).collect();
let meters = BTreeSet::from([id]);
let mut kept: Vec<&N> = Vec::new();
for child in &children {
let child_id = child.component_id();
if !reaches_any_below(graph, child_id, &child_ids)?
&& reached_only_through(graph, child_id, &meters)?
{
kept.push(child);
}
}
if kept.is_empty() {
return Ok(own);
}
let standing = stands_alone(graph, id, policy)?;
if standing && is_grid_meter(graph, component)? {
return Ok(own);
}
let empty = || Error::internal("Meter children sum is empty.");
let terms = kept
.iter()
.map(|c| child_best_effort_term(graph, c, policy))
.collect::<Result<Vec<_>, _>>()?;
let best = sum(terms).ok_or_else(empty)?;
if standing {
return Ok(own.coalesce(best));
}
if policy.meters_first() {
Ok(own.coalesce(best))
} else {
let exact =
sum(kept.iter().map(|c| Expr::component(c.component_id()))).ok_or_else(empty)?;
let last_resort = if kept.len() > 1 {
best
} else if kept[0].is_meter() {
Expr::None
} else {
Expr::number(0.0)
};
Ok(exact.coalesce(own).coalesce(last_resort))
}
}
fn exact_sum(ids: &[u64]) -> Option<Expr> {
sum(ids.iter().map(|&id| Expr::component(id)))
}
fn best_effort_sum(ids: &[u64]) -> Option<Expr> {
sum(ids
.iter()
.map(|&id| Expr::coalesce(Expr::component(id), Expr::number(0.0))))
}
fn diamond_term(
components: &[u64],
meters: &[u64],
policy: SourcePreference,
) -> Result<Expr, Error> {
let empty = || Error::internal("Diamond measurement with no meters or components.");
let component_sum = exact_sum(components).ok_or_else(empty)?;
let meter_best = best_effort_sum(meters).ok_or_else(empty)?;
Ok(if policy.meters_first() {
let meter_sum = exact_sum(meters).ok_or_else(empty)?;
meter_sum.coalesce(component_sum).coalesce(meter_best)
} else {
component_sum.coalesce(meter_best)
})
}
fn subtraction_term(
parent_meters: &[u64],
subtracted: &[u64],
components: &[u64],
policy: SourcePreference,
) -> Result<Expr, Error> {
let empty = || Error::internal("Subtraction measurement with no components.");
let no_meters = || Error::internal("Subtraction measurement with no parent meters.");
let meter_sum = exact_sum(parent_meters).ok_or_else(no_meters)?;
let difference = subtracted
.iter()
.fold(meter_sum, |expr, &m| expr - Expr::component(m));
let exact = exact_sum(components).ok_or_else(empty)?;
let best = best_effort_sum(components).ok_or_else(empty)?;
Ok(if policy.meters_first() {
difference.coalesce(best)
} else {
let last_resort = if components.len() > 1 {
best
} else {
Expr::number(0.0)
};
exact.coalesce(difference).coalesce(last_resort)
})
}
fn child_best_effort_term<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
child: &N,
policy: SourcePreference,
) -> Result<Expr, Error> {
let child_id = child.component_id();
if !child.is_meter() {
return Ok(Expr::coalesce(Expr::component(child_id), Expr::number(0.0)));
}
let meters = BTreeSet::from([child_id]);
for successor in graph.successors(child_id)? {
if !reached_only_through(graph, successor.component_id(), &meters)? {
return Ok(Expr::component(child_id));
}
}
measure(graph, child_id, policy)
}
fn stands_alone<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
id: u64,
policy: SourcePreference,
) -> Result<bool, Error> {
let successors: Vec<&N> = graph.successors(id)?.collect();
if successors.is_empty() {
return Ok(true);
}
if !successors.iter().any(|successor| successor.is_meter()) {
return is_grid_meter(graph, graph.component(id)?);
}
if !policy.allows_meter_chains() || successors.len() > 1 {
return Ok(true);
}
graph.is_component_meter(successors[0].component_id())
}
pub(crate) fn is_grid_meter<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
component: &N,
) -> Result<bool, Error> {
is_grid_meter_inner(graph, component, &mut BTreeSet::new())
}
fn is_grid_meter_inner<N: Node, E: Edge>(
graph: &ComponentGraph<N, E>,
component: &N,
checking: &mut BTreeSet<u64>,
) -> Result<bool, Error> {
let id = component.component_id();
if !checking.insert(id) || !component.is_meter() || graph.is_component_meter(id)? {
return Ok(false);
}
let has_no_siblings = graph.siblings_from_predecessors(id)?.next().is_none();
for predecessor in graph.predecessors(id)? {
if predecessor.is_grid()
|| (has_no_siblings && is_grid_meter_inner(graph, predecessor, checking)?)
{
return Ok(true);
}
}
Ok(false)
}
fn is_measurable_component<N: Node>(node: &N, config: &ComponentGraphConfig) -> bool {
node.is_battery_inverter(config)
|| node.is_chp()
|| node.is_pv_inverter()
|| node.is_ev_charger()
|| node.is_wind_turbine()
|| node.is_steam_boiler()
}
fn sum(exprs: impl IntoIterator<Item = Expr>) -> Option<Expr> {
exprs.into_iter().reduce(|a, b| a + b)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::{SourcePreference, aggregate};
use crate::graph::test_utils::ComponentGraphBuilder;
use crate::{ComponentGraphConfig, Error};
#[test]
fn test_no_meters() {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let inverter = builder.solar_inverter();
builder.connect(grid, inverter);
let graph = builder.build(None).unwrap();
let expr = graph.pv_formula(None).unwrap().to_string();
assert_eq!(expr, "COALESCE(#1, 0.0)");
let inverter = builder.solar_inverter();
builder.connect(grid, inverter);
let graph = builder.build(None).unwrap();
let expr = graph.pv_formula(None).unwrap().to_string();
assert_eq!(expr, "COALESCE(#1, 0.0) + COALESCE(#2, 0.0)");
}
#[test]
fn test_validation_accepts_passthrough_predecessor() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let pt = builder.power_transformer();
let meter = builder.meter();
let inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(grid, pt);
builder.connect(pt, meter);
builder.connect(meter, inverter);
builder.connect(inverter, battery);
let _graph = builder.build(None)?;
Ok(())
}
#[test]
fn test_acyclicity_detects_passthrough_only_cycle() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let meter = builder.meter();
let inverter = builder.battery_inverter();
let battery = builder.battery();
let pt1 = builder.power_transformer();
let pt2 = builder.power_transformer();
let pt3 = builder.power_transformer();
builder.connect(grid, meter);
builder.connect(meter, inverter);
builder.connect(inverter, battery);
builder.connect(grid, pt1);
builder.connect(pt1, pt2);
builder.connect(pt2, pt3);
builder.connect(pt3, pt1);
assert!(
builder.build(None).is_err(),
"PT-only cycle reachable from the GCP must be detected at construction time"
);
Ok(())
}
#[test]
fn test_ensure_root_tolerates_passthrough_predecessor() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let pt = builder.power_transformer();
let meter = builder.meter();
let inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(pt, grid);
builder.connect(grid, meter);
builder.connect(meter, inverter);
builder.connect(inverter, battery);
let _graph = builder.build(None)?;
Ok(())
}
#[test]
fn test_grid_formula_skips_passthrough_at_root() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let pt = builder.power_transformer();
let meter = builder.meter();
let inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(grid, pt);
builder.connect(pt, meter);
builder.connect(meter, inverter);
builder.connect(inverter, battery);
let graph = builder.build(None)?;
let formula = graph.grid_formula()?.to_string();
assert!(
!formula.contains("#1"),
"PowerTransformer #1 must not appear in grid_formula, got {formula:?}",
);
assert_eq!(formula, "COALESCE(#2, #3, 0.0)");
Ok(())
}
#[test]
fn test_grid_formula_skips_passthrough_successor() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let meter = builder.meter();
let pt = builder.power_transformer();
let inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(grid, meter);
builder.connect(meter, pt);
builder.connect(pt, inverter);
builder.connect(inverter, battery);
let graph = builder.build(None)?;
let formula = graph.grid_formula()?.to_string();
assert!(
!formula.contains("#2"),
"PowerTransformer #2 must not appear in grid_formula, got {formula:?}",
);
assert_eq!(formula, "COALESCE(#1, #3, 0.0)");
Ok(())
}
#[test]
fn test_battery_formula_finds_meter_through_passthrough() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let meter = builder.meter();
let pt = builder.power_transformer();
let inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(grid, meter);
builder.connect(meter, pt);
builder.connect(pt, inverter);
builder.connect(inverter, battery);
let graph = builder.build(None)?;
let formula = graph.battery_formula(None)?.to_string();
assert_eq!(formula, "COALESCE(#3, #1, 0.0)");
Ok(())
}
#[test]
fn test_aggregate() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let meter_bat_chain = builder.meter_bat_chain(1, 1);
builder.connect(grid_meter, meter_bat_chain);
assert_eq!(grid_meter.component_id(), 1);
assert_eq!(meter_bat_chain.component_id(), 2);
let graph = builder.build(None)?;
let expr = aggregate(
&graph,
BTreeSet::from([1]),
SourcePreference::MetersFirstWithChains,
)?;
assert_eq!(expr.to_string(), "#1");
let expr = aggregate(
&graph,
BTreeSet::from([1, 2]),
SourcePreference::MetersFirstWithChains,
)?;
assert_eq!(expr.to_string(), "#1 + COALESCE(#2, #3, 0.0)");
let expr = aggregate(
&graph,
BTreeSet::from([1, 2]),
SourcePreference::ComponentsFirst,
)?;
assert_eq!(expr.to_string(), "#1 + COALESCE(#3, #2, 0.0)");
let expr = aggregate(
&graph,
BTreeSet::from([1, 2]),
SourcePreference::MetersFirst,
)?;
assert_eq!(expr.to_string(), "#1 + COALESCE(#2, #3, 0.0)");
let expr = aggregate(&graph, BTreeSet::from([3]), SourcePreference::MetersFirst)?;
assert_eq!(expr.to_string(), "COALESCE(#2, #3, 0.0)");
let expr = aggregate(
&graph,
BTreeSet::from([3]),
SourcePreference::MetersFirstWithChains,
)?;
assert_eq!(expr.to_string(), "COALESCE(#2, #3, 0.0)");
let expr = aggregate(
&graph,
BTreeSet::from([2]),
SourcePreference::MetersFirstWithChains,
)?;
assert_eq!(expr.to_string(), "COALESCE(#2, #3, 0.0)");
let graph = builder.build(Some(
ComponentGraphConfig::builder()
.disable_fallback_components(true)
.build(),
))?;
let expr = aggregate(
&graph,
BTreeSet::from([1, 2]),
SourcePreference::ComponentsFirst,
)?;
assert_eq!(expr.to_string(), "#1 + #2");
let expr = aggregate(
&graph,
BTreeSet::from([1, 2]),
SourcePreference::MetersFirst,
)?;
assert_eq!(expr.to_string(), "#1 + #2");
let expr = aggregate(&graph, BTreeSet::from([3]), SourcePreference::MetersFirst)?;
assert_eq!(expr.to_string(), "#3");
let meter_bat_chain = builder.meter_bat_chain(3, 3);
builder.connect(grid_meter, meter_bat_chain);
assert_eq!(meter_bat_chain.component_id(), 5);
let graph = builder.build(None)?;
let expr = aggregate(
&graph,
BTreeSet::from([3, 5]),
SourcePreference::ComponentsFirst,
)?;
assert_eq!(
expr.to_string(),
concat!(
"COALESCE(#3, #2, 0.0) + ",
"COALESCE(",
"#8 + #7 + #6, ",
"#5, ",
"COALESCE(#8, 0.0) + COALESCE(#7, 0.0) + COALESCE(#6, 0.0)",
")"
)
);
let expr = aggregate(
&graph,
BTreeSet::from([2, 5]),
SourcePreference::MetersFirst,
)?;
assert_eq!(
expr.to_string(),
concat!(
"COALESCE(#2, #3, 0.0) + ",
"COALESCE(#5, COALESCE(#8, 0.0) + COALESCE(#7, 0.0) + COALESCE(#6, 0.0))"
)
);
let expr = aggregate(
&graph,
BTreeSet::from([2, 6, 7, 8]),
SourcePreference::MetersFirst,
)?;
assert_eq!(
expr.to_string(),
concat!(
"COALESCE(#2, #3, 0.0) + ",
"COALESCE(#5, COALESCE(#8, 0.0) + COALESCE(#7, 0.0) + COALESCE(#6, 0.0))"
)
);
let expr = aggregate(
&graph,
BTreeSet::from([2, 7, 8]),
SourcePreference::MetersFirst,
)?;
assert_eq!(
expr.to_string(),
concat!(
"COALESCE(#2, #3, 0.0) + ",
"COALESCE(#5 - #6, COALESCE(#7, 0.0) + COALESCE(#8, 0.0))"
)
);
let graph = builder.build(Some(
ComponentGraphConfig::builder()
.disable_fallback_components(true)
.build(),
))?;
let expr = aggregate(
&graph,
BTreeSet::from([3, 5]),
SourcePreference::ComponentsFirst,
)?;
assert_eq!(expr.to_string(), "#3 + #5");
let expr = aggregate(
&graph,
BTreeSet::from([2, 5]),
SourcePreference::MetersFirst,
)?;
assert_eq!(expr.to_string(), "#2 + #5");
let expr = aggregate(
&graph,
BTreeSet::from([2, 6, 7, 8]),
SourcePreference::MetersFirst,
)?;
assert_eq!(expr.to_string(), "#2 + #6 + #7 + #8");
let expr = aggregate(
&graph,
BTreeSet::from([2, 7, 8]),
SourcePreference::MetersFirst,
)?;
assert_eq!(expr.to_string(), "#2 + #7 + #8");
let meter = builder.meter();
let chp = builder.chp();
let pv_inverter = builder.solar_inverter();
builder.connect(grid_meter, meter);
builder.connect(meter, chp);
builder.connect(meter, pv_inverter);
assert_eq!(meter.component_id(), 12);
assert_eq!(chp.component_id(), 13);
assert_eq!(pv_inverter.component_id(), 14);
let graph = builder.build(None)?;
let expr = aggregate(
&graph,
BTreeSet::from([5, 12]),
SourcePreference::MetersFirst,
)?;
assert_eq!(
expr.to_string(),
concat!(
"COALESCE(#5, COALESCE(#8, 0.0) + COALESCE(#7, 0.0) + COALESCE(#6, 0.0)) + ",
"COALESCE(#12, COALESCE(#14, 0.0) + COALESCE(#13, 0.0))"
)
);
let expr = aggregate(
&graph,
BTreeSet::from([7, 14]),
SourcePreference::ComponentsFirst,
)?;
assert_eq!(
expr.to_string(),
"COALESCE(#7, #5 - #6 - #8, 0.0) + COALESCE(#14, #12 - #13, 0.0)"
);
Ok(())
}
#[test]
fn test_aggregate_through_meter_chain() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let meter1 = builder.meter();
let meter2 = builder.meter();
let bat_chain = builder.meter_bat_chain(1, 1);
let pv_chain = builder.meter_pv_chain(1);
builder.connect(grid, meter1);
builder.connect(meter1, meter2);
builder.connect(meter2, bat_chain);
builder.connect(meter2, pv_chain);
let graph = builder.build(None)?;
let expr = aggregate(
&graph,
BTreeSet::from([meter1.component_id()]),
SourcePreference::MetersFirstWithChains,
)?;
assert_eq!(expr.to_string(), "COALESCE(#1, #2)");
Ok(())
}
#[test]
fn test_measurement_points() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let meter = builder.meter();
let metered = builder.battery_inverter();
let battery = builder.battery();
builder.connect(grid, meter);
builder.connect(meter, metered);
builder.connect(metered, battery);
let meterless = builder.battery_inverter();
let meterless_battery = builder.battery();
builder.connect(grid, meterless);
builder.connect(meterless, meterless_battery);
let graph = builder.build(None)?;
assert_eq!(
super::measurement_points(&graph, &BTreeSet::from([metered.component_id()]))?,
vec![super::Measurement::Single(meter.component_id())],
);
assert_eq!(
super::measurement_points(&graph, &BTreeSet::from([meterless.component_id()]))?,
vec![super::Measurement::Single(meterless.component_id())],
);
Ok(())
}
#[test]
fn test_aggregate_diamond() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let m1 = builder.meter();
let m2 = builder.meter();
let inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(grid, m1);
builder.connect(grid, m2);
builder.connect(m1, inverter);
builder.connect(m2, inverter);
builder.connect(inverter, battery);
let graph = builder.build(None)?;
let targets = BTreeSet::from([inverter.component_id()]);
assert_eq!(
super::measurement_points(&graph, &targets)?,
vec![super::Measurement::Diamond {
components: vec![inverter.component_id()],
meters: vec![m1.component_id(), m2.component_id()],
}],
);
assert_eq!(
aggregate(&graph, targets.clone(), SourcePreference::MetersFirst)?.to_string(),
"COALESCE(#1 + #2, #3, COALESCE(#1, 0.0) + COALESCE(#2, 0.0))",
);
assert_eq!(
aggregate(&graph, targets, SourcePreference::ComponentsFirst)?.to_string(),
"COALESCE(#3, COALESCE(#1, 0.0) + COALESCE(#2, 0.0))",
);
Ok(())
}
#[test]
fn test_stands_alone() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let component_meter = builder.meter();
let inv1 = builder.battery_inverter();
let inv2 = builder.battery_inverter();
let battery = builder.battery();
builder.connect(grid, component_meter);
builder.connect(component_meter, inv1);
builder.connect(component_meter, inv2);
builder.connect(inv1, battery);
builder.connect(inv2, battery);
let mixed_meter = builder.meter();
let child_meter = builder.meter();
let inv3 = builder.battery_inverter();
let battery2 = builder.battery();
builder.connect(grid, mixed_meter);
builder.connect(mixed_meter, child_meter);
builder.connect(mixed_meter, inv3);
builder.connect(inv3, battery2);
let graph = builder.build(None)?;
assert!(!super::stands_alone(
&graph,
component_meter.component_id(),
SourcePreference::MetersFirst,
)?);
assert!(super::stands_alone(
&graph,
mixed_meter.component_id(),
SourcePreference::MetersFirstWithChains,
)?);
Ok(())
}
#[test]
fn test_stands_alone_total_through_child_meter() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let battery_meter = builder.meter_bat_chain(1, 1);
builder.connect(mixed_meter, battery_meter);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(None)?;
let expr = aggregate(
&graph,
BTreeSet::from([mixed_meter.component_id()]),
SourcePreference::MetersFirst,
)?;
assert_eq!(
expr.to_string(),
"COALESCE(#2, COALESCE(#4, #5, 0.0) + COALESCE(#3, 0.0))",
);
Ok(())
}
#[test]
fn test_substitution_asymmetric_diamond_order_independent() -> Result<(), Error> {
for shared_id_first in [false, true] {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let m_a = builder.meter();
let m_b = builder.meter();
builder.connect(grid_meter, m_a);
builder.connect(grid_meter, m_b);
let first = builder.solar_inverter();
let second = builder.solar_inverter();
let (a, b) = if shared_id_first {
(second, first)
} else {
(first, second)
};
builder.connect(m_a, a);
builder.connect(m_a, b);
builder.connect(m_b, b);
let graph = builder.build(None)?;
let targets = BTreeSet::from([a.component_id(), b.component_id()]);
assert_eq!(
super::measurement_points(&graph, &targets)?,
vec![super::Measurement::Diamond {
components: vec![4, 5],
meters: vec![m_a.component_id(), m_b.component_id()],
}],
"shared_id_first: {shared_id_first}",
);
assert_eq!(
aggregate(&graph, targets, SourcePreference::MetersFirst)?.to_string(),
"COALESCE(#2 + #3, #4 + #5, COALESCE(#2, 0.0) + COALESCE(#3, 0.0))",
"shared_id_first: {shared_id_first}",
);
}
Ok(())
}
#[test]
fn test_substitution_rejects_parent_meter_as_sibling() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let battery_meter = builder.meter();
let battery_inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(mixed_meter, battery_meter);
builder.connect(battery_meter, battery_inverter);
builder.connect(battery_inverter, battery);
builder.connect(mixed_meter, battery_inverter);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
super::measurement_points(&graph, &BTreeSet::from([3, 4, 5]))?,
vec![super::Measurement::Single(2)],
);
Ok(())
}
#[test]
fn test_child_meter_diamond_stays_bare() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let pv1 = builder.solar_inverter();
builder.connect(mixed_meter, pv1);
let m_a = builder.meter();
let m_b = builder.meter();
builder.connect(mixed_meter, m_a);
builder.connect(mixed_meter, m_b);
let pv2 = builder.solar_inverter();
builder.connect(m_a, pv2);
builder.connect(m_b, pv2);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
aggregate(
&graph,
BTreeSet::from([mixed_meter.component_id()]),
SourcePreference::MetersFirst,
)?
.to_string(),
"COALESCE(#2, #5 + #4 + COALESCE(#3, 0.0))",
);
Ok(())
}
#[test]
fn test_aggregate_subtraction() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let mixed_meter = builder.meter();
builder.connect(grid, main_meter);
builder.connect(main_meter, mixed_meter);
let inverters: Vec<_> = (0..5).map(|_| builder.solar_inverter()).collect();
for inverter in &inverters {
builder.connect(mixed_meter, *inverter);
}
let sub_meter = builder.meter();
builder.connect(mixed_meter, sub_meter);
let load_meter = builder.meter();
builder.connect(main_meter, load_meter);
let graph = builder.build(None)?;
let targets = BTreeSet::from([3, 4, 5, 6, 7]);
assert_eq!(
super::measurement_points(&graph, &targets)?,
vec![super::Measurement::Subtraction {
parent_meters: vec![mixed_meter.component_id()],
subtracted: vec![sub_meter.component_id()],
components: vec![3, 4, 5, 6, 7],
}],
);
assert_eq!(
aggregate(&graph, targets.clone(), SourcePreference::MetersFirst)?.to_string(),
concat!(
"COALESCE(#2 - #8, ",
"COALESCE(#3, 0.0) + COALESCE(#4, 0.0) + COALESCE(#5, 0.0) + ",
"COALESCE(#6, 0.0) + COALESCE(#7, 0.0))"
),
);
assert_eq!(
aggregate(&graph, targets, SourcePreference::ComponentsFirst)?.to_string(),
concat!(
"COALESCE(#3 + #4 + #5 + #6 + #7, #2 - #8, ",
"COALESCE(#3, 0.0) + COALESCE(#4, 0.0) + COALESCE(#5, 0.0) + ",
"COALESCE(#6, 0.0) + COALESCE(#7, 0.0))"
),
);
assert_eq!(
graph.pv_formula(None)?.to_string(),
concat!(
"COALESCE(#3 + #4 + #5 + #6 + #7, #2 - #8, ",
"COALESCE(#3, 0.0) + COALESCE(#4, 0.0) + COALESCE(#5, 0.0) + ",
"COALESCE(#6, 0.0) + COALESCE(#7, 0.0))"
),
);
assert_eq!(
graph.pv_formula(Some(BTreeSet::from([3])))?.to_string(),
"COALESCE(#3, #2 - #4 - #5 - #6 - #7 - #8, 0.0)",
);
Ok(())
}
#[test]
fn test_aggregate_subtraction_diamond() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let m_a = builder.meter();
let m_b = builder.meter();
builder.connect(grid_meter, m_a);
builder.connect(grid_meter, m_b);
let pv1 = builder.solar_inverter();
let pv2 = builder.solar_inverter();
for meter in [m_a, m_b] {
builder.connect(meter, pv1);
builder.connect(meter, pv2);
}
let graph = builder.build(None)?;
let targets = BTreeSet::from([pv1.component_id()]);
assert_eq!(
super::measurement_points(&graph, &targets)?,
vec![super::Measurement::Subtraction {
parent_meters: vec![m_a.component_id(), m_b.component_id()],
subtracted: vec![pv2.component_id()],
components: vec![pv1.component_id()],
}],
);
assert_eq!(
aggregate(&graph, targets.clone(), SourcePreference::ComponentsFirst)?.to_string(),
"COALESCE(#4, #2 + #3 - #5, 0.0)",
);
assert_eq!(
aggregate(&graph, targets, SourcePreference::MetersFirst)?.to_string(),
"COALESCE(#2 + #3 - #5, #4, 0.0)",
);
assert_eq!(
super::measurement_points(&graph, &BTreeSet::from([4, 5]))?,
vec![super::Measurement::Diamond {
components: vec![4, 5],
meters: vec![2, 3],
}],
);
Ok(())
}
#[test]
fn test_subtraction_diamond_subsumes_earlier_single() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let m_a = builder.meter();
let m_b = builder.meter();
builder.connect(grid_meter, m_a);
builder.connect(grid_meter, m_b);
let pv1 = builder.solar_inverter();
let bat_inverter = builder.battery_inverter();
let battery = builder.battery();
let pv2 = builder.solar_inverter();
builder.connect(m_a, pv1);
builder.connect(m_a, bat_inverter);
builder.connect(bat_inverter, battery);
builder.connect(m_a, pv2);
builder.connect(m_b, pv2);
let graph = builder.build(None)?;
let targets = BTreeSet::from([pv1.component_id(), pv2.component_id()]);
assert_eq!(
super::measurement_points(&graph, &targets)?,
vec![super::Measurement::Subtraction {
parent_meters: vec![m_a.component_id(), m_b.component_id()],
subtracted: vec![bat_inverter.component_id()],
components: vec![pv1.component_id(), pv2.component_id()],
}],
);
assert_eq!(
aggregate(&graph, targets, SourcePreference::ComponentsFirst)?.to_string(),
"COALESCE(#4 + #7, #2 + #3 - #5, COALESCE(#4, 0.0) + COALESCE(#7, 0.0))",
);
Ok(())
}
#[test]
fn test_subtraction_diamond_disqualified_by_grid_meters() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let m1 = builder.meter();
let m2 = builder.meter();
builder.connect(grid, m1);
builder.connect(grid, m2);
let pv = builder.solar_inverter();
let sub_meter = builder.meter();
for meter in [m1, m2] {
builder.connect(meter, pv);
builder.connect(meter, sub_meter);
}
let graph = builder.build(None)?;
assert_eq!(graph.pv_formula(None)?.to_string(), "COALESCE(#3, 0.0)");
Ok(())
}
#[test]
fn test_aggregate_subtraction_component_siblings() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let pv_meter = builder.meter_pv_chain(3);
builder.connect(grid, main_meter);
builder.connect(main_meter, pv_meter);
let graph = builder.build(None)?;
assert_eq!(
graph.pv_formula(Some(BTreeSet::from([3])))?.to_string(),
"COALESCE(#3, #2 - #4 - #5, 0.0)",
);
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let mixed_meter = builder.meter();
builder.connect(grid, main_meter);
builder.connect(main_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let battery_inverter = builder.inv_bat_chain(1);
builder.connect(mixed_meter, battery_inverter);
let load_meter = builder.meter();
builder.connect(main_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
graph.pv_formula(None)?.to_string(),
"COALESCE(#3, #2 - #4, 0.0)",
);
assert_eq!(
graph.battery_formula(None)?.to_string(),
"COALESCE(#4, #2 - #3, 0.0)",
);
Ok(())
}
#[test]
fn test_aggregate_subtraction_component_sub_meter() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let mixed_meter = builder.meter();
builder.connect(grid, main_meter);
builder.connect(main_meter, mixed_meter);
let pv1 = builder.solar_inverter();
let pv2 = builder.solar_inverter();
builder.connect(mixed_meter, pv1);
builder.connect(mixed_meter, pv2);
let battery_meter = builder.meter_bat_chain(1, 1);
builder.connect(mixed_meter, battery_meter);
let load_meter = builder.meter();
builder.connect(main_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
graph.pv_formula(None)?.to_string(),
"COALESCE(#3 + #4, #2 - #5, COALESCE(#3, 0.0) + COALESCE(#4, 0.0))",
);
assert_eq!(
graph.battery_formula(None)?.to_string(),
"COALESCE(#6, #5, 0.0)",
);
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let mixed_meter = builder.meter();
builder.connect(grid, main_meter);
builder.connect(main_meter, mixed_meter);
let battery_inverter = builder.inv_bat_chain(1);
builder.connect(mixed_meter, battery_inverter);
let pv_meter = builder.meter_pv_chain(1);
builder.connect(mixed_meter, pv_meter);
let load_meter = builder.meter();
builder.connect(main_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
graph.battery_formula(None)?.to_string(),
"COALESCE(#3, #2 - #5, 0.0)",
);
assert_eq!(graph.pv_formula(None)?.to_string(), "COALESCE(#6, #5, 0.0)");
Ok(())
}
#[test]
fn test_subtraction_disqualifiers() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let mixed_meter = builder.meter();
builder.connect(grid, main_meter);
builder.connect(main_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let hybrid = builder.add_component(crate::ComponentCategory::Inverter(
crate::InverterType::Hybrid,
));
let battery = builder.battery();
builder.connect(mixed_meter, hybrid);
builder.connect(hybrid, battery);
let graph = builder.build(None)?;
assert_eq!(graph.pv_formula(None)?.to_string(), "COALESCE(#3, 0.0)");
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let mixed_meter = builder.meter();
builder.connect(grid, main_meter);
builder.connect(main_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let pv_meter = builder.meter_pv_chain(1);
builder.connect(mixed_meter, pv_meter);
let graph = builder.build(None)?;
assert_eq!(
graph.pv_formula(None)?.to_string(),
"COALESCE(#3, 0.0) + COALESCE(#5, #4, 0.0)",
);
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let pv = builder.solar_inverter();
builder.connect(grid_meter, pv);
let sub_meter = builder.meter();
builder.connect(grid_meter, sub_meter);
let graph = builder.build(None)?;
assert_eq!(graph.pv_formula(None)?.to_string(), "COALESCE(#2, 0.0)");
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
let fallback_grid_meter = builder.meter();
builder.connect(grid, grid_meter);
builder.connect(grid_meter, fallback_grid_meter);
let pv = builder.solar_inverter();
builder.connect(fallback_grid_meter, pv);
let sub_meter = builder.meter();
builder.connect(fallback_grid_meter, sub_meter);
let graph = builder.build(None)?;
assert_eq!(graph.pv_formula(None)?.to_string(), "COALESCE(#3, 0.0)");
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let mixed_meter = builder.meter();
builder.connect(grid, main_meter);
builder.connect(main_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let sub_meter = builder.meter();
builder.connect(mixed_meter, sub_meter);
builder.connect(main_meter, sub_meter);
let graph = builder.build(None)?;
assert_eq!(graph.pv_formula(None)?.to_string(), "COALESCE(#3, 0.0)");
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let main_meter = builder.meter();
let mixed_meter = builder.meter();
let other_meter = builder.meter();
builder.connect(grid, main_meter);
builder.connect(main_meter, mixed_meter);
builder.connect(main_meter, other_meter);
let pv = builder.solar_inverter();
let pv_external = builder.solar_inverter();
let sub_meter = builder.meter();
builder.connect(mixed_meter, pv);
builder.connect(mixed_meter, pv_external);
builder.connect(mixed_meter, sub_meter);
builder.connect(grid, pv_external);
let graph = builder.build(Some(
ComponentGraphConfig::builder()
.allow_component_validation_failures(true)
.build(),
))?;
assert_eq!(
graph.pv_formula(None)?.to_string(),
"COALESCE(#4, 0.0) + COALESCE(#5, 0.0)",
);
Ok(())
}
#[test]
fn test_children_fallback_drops_feeder_of_sibling() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let battery_meter = builder.meter();
let battery_inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(mixed_meter, battery_meter);
builder.connect(battery_meter, battery_inverter);
builder.connect(battery_inverter, battery);
builder.connect(mixed_meter, battery_inverter);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
aggregate(
&graph,
BTreeSet::from([mixed_meter.component_id()]),
SourcePreference::MetersFirst,
)?
.to_string(),
"COALESCE(#2, COALESCE(#5, 0.0) + COALESCE(#3, 0.0))",
);
Ok(())
}
#[test]
fn test_children_fallback_drops_transitive_feeder() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let sub_a = builder.meter();
builder.connect(mixed_meter, sub_a);
let sub_b = builder.meter();
builder.connect(sub_a, sub_b);
let battery_inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(sub_b, battery_inverter);
builder.connect(mixed_meter, battery_inverter);
builder.connect(battery_inverter, battery);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
aggregate(
&graph,
BTreeSet::from([mixed_meter.component_id()]),
SourcePreference::MetersFirst,
)?
.to_string(),
"COALESCE(#2, #5, 0.0)",
);
Ok(())
}
#[test]
fn test_children_fallback_drops_device_feeder() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let battery_inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(mixed_meter, battery_inverter);
builder.connect(battery_inverter, battery);
let sub_meter = builder.meter();
builder.connect(mixed_meter, sub_meter);
builder.connect(battery_inverter, sub_meter);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(Some(
ComponentGraphConfig::builder()
.allow_component_validation_failures(true)
.build(),
))?;
assert_eq!(
aggregate(
&graph,
BTreeSet::from([mixed_meter.component_id()]),
SourcePreference::MetersFirst,
)?
.to_string(),
"#2",
);
Ok(())
}
#[test]
fn test_children_fallback_drops_child_shared_with_parallel_meter() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let meter_a = builder.meter();
builder.connect(grid_meter, meter_a);
let meter_b = builder.meter();
builder.connect(grid_meter, meter_b);
let battery_inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(meter_a, battery_inverter);
builder.connect(meter_b, battery_inverter);
builder.connect(battery_inverter, battery);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
aggregate(
&graph,
BTreeSet::from([meter_a.component_id(), meter_b.component_id()]),
SourcePreference::MetersFirst,
)?
.to_string(),
"#2 + #3",
);
Ok(())
}
#[test]
fn test_subsumed_sub_meter_stays_claimed() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let pv = builder.solar_inverter();
builder.connect(grid_meter, pv);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let sub_meter = builder.meter();
let chp = builder.chp();
builder.connect(mixed_meter, sub_meter);
builder.connect(mixed_meter, chp);
let nested_chp = builder.chp();
builder.connect(sub_meter, nested_chp);
let graph = builder.build(None)?;
assert_eq!(
aggregate(
&graph,
BTreeSet::from([
sub_meter.component_id(),
chp.component_id(),
nested_chp.component_id(),
]),
SourcePreference::MetersFirst,
)?
.to_string(),
"COALESCE(#3, COALESCE(#5, 0.0) + COALESCE(#4, #6, 0.0))",
);
Ok(())
}
#[test]
fn test_grid_meter_never_backed_by_children() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let fallback_grid_meter = builder.meter();
builder.connect(grid_meter, fallback_grid_meter);
let pv = builder.solar_inverter();
let chp = builder.chp();
builder.connect(fallback_grid_meter, pv);
builder.connect(fallback_grid_meter, chp);
let graph = builder.build(None)?;
assert_eq!(
aggregate(
&graph,
BTreeSet::from([grid_meter.component_id()]),
SourcePreference::MetersFirstWithChains,
)?
.to_string(),
"COALESCE(#1, #2)",
);
assert_eq!(
aggregate(
&graph,
BTreeSet::from([fallback_grid_meter.component_id()]),
SourcePreference::MetersFirst,
)?
.to_string(),
"#2",
);
Ok(())
}
#[test]
fn test_subtraction_covered_feeder() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let sub_meter = builder.meter();
builder.connect(mixed_meter, sub_meter);
let battery_inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(mixed_meter, battery_inverter);
builder.connect(sub_meter, battery_inverter);
builder.connect(battery_inverter, battery);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(
graph.pv_formula(None)?.to_string(),
"COALESCE(#3, #2 - #5, 0.0)",
);
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let mixed_meter = builder.meter();
builder.connect(grid_meter, mixed_meter);
let pv = builder.solar_inverter();
builder.connect(mixed_meter, pv);
let sub_meter = builder.meter();
builder.connect(mixed_meter, sub_meter);
let battery_inverter = builder.battery_inverter();
let battery = builder.battery();
builder.connect(mixed_meter, battery_inverter);
builder.connect(sub_meter, battery_inverter);
builder.connect(battery_inverter, battery);
let chp = builder.chp();
builder.connect(sub_meter, chp);
let load_meter = builder.meter();
builder.connect(grid_meter, load_meter);
let graph = builder.build(None)?;
assert_eq!(graph.pv_formula(None)?.to_string(), "COALESCE(#3, 0.0)");
Ok(())
}
#[test]
fn test_grid_meter_second_feed_order_independent() -> Result<(), Error> {
for grid_edge_first in [true, false] {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let other_meter = builder.meter();
builder.connect(grid, other_meter);
let meter = builder.meter();
if grid_edge_first {
builder.connect(grid, meter);
builder.connect(other_meter, meter);
} else {
builder.connect(other_meter, meter);
builder.connect(grid, meter);
}
let pv = builder.solar_inverter();
builder.connect(meter, pv);
let sub_meter = builder.meter();
builder.connect(meter, sub_meter);
let graph = builder.build(Some(
ComponentGraphConfig::builder()
.allow_component_validation_failures(true)
.build(),
))?;
assert_eq!(
graph.pv_formula(None)?.to_string(),
"COALESCE(#3, 0.0)",
"grid_edge_first: {grid_edge_first}",
);
}
Ok(())
}
#[test]
fn test_off_root_cycle_terminates() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let grid_meter = builder.meter();
builder.connect(grid, grid_meter);
let meter = builder.meter();
builder.connect(grid_meter, meter);
let pv = builder.solar_inverter();
builder.connect(meter, pv);
let x = builder.meter();
let y = builder.meter();
builder.connect(x, y);
builder.connect(y, x);
builder.connect(x, meter);
builder.connect(x, pv);
let graph = builder.build(Some(
ComponentGraphConfig::builder()
.allow_unconnected_components(true)
.allow_component_validation_failures(true)
.build(),
))?;
graph.pv_formula(None)?;
Ok(())
}
}