diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 112c6d8..0e85354 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,13 +16,7 @@ jobs: - name: Fetch sources uses: actions/checkout@v4 with: - submodules: true - - - name: Check formatting - run: cargo fmt --check - - - name: Run linter - run: cargo clippy -- -W clippy::unwrap_used -W clippy::expect_used -W clippy::panic + submodules: recursive - name: Run tests - run: cargo test + uses: frequenz-floss/gh-action-cargo-test@v1.0.0 diff --git a/README.md b/README.md index 12082a7..6cf3789 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ This is a library for representing the components of a microgrid and the connections between them as a Directed Acyclic Graph (DAG). Current features: - - validating the graph - - traversing the graph + - Validating the graph + - Traversing the graph + - Formula generation Upcoming features: - - Formula generation - Python bindings diff --git a/src/graph.rs b/src/graph.rs index f7f3d74..d77b590 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -9,6 +9,7 @@ mod meter_roles; mod retrieval; mod validation; +mod formulas; pub mod iterators; use crate::{Edge, Node}; @@ -42,53 +43,4 @@ where } #[cfg(test)] -mod test_types { - //! This module contains the `TestComponent` and `TestConnection` types, - //! which implement the `Node` and `Edge` traits respectively. - //! - //! They are shared by all the test modules in the `graph` module. - - use crate::{ComponentCategory, Edge, Node}; - - #[derive(Clone, Debug, PartialEq)] - pub(super) struct TestComponent(u64, ComponentCategory); - - impl TestComponent { - pub(super) fn new(id: u64, category: ComponentCategory) -> Self { - TestComponent(id, category) - } - } - - impl Node for TestComponent { - fn component_id(&self) -> u64 { - self.0 - } - - fn category(&self) -> ComponentCategory { - self.1.clone() - } - - fn is_supported(&self) -> bool { - true - } - } - - #[derive(Clone, Debug, PartialEq)] - pub(super) struct TestConnection(u64, u64); - - impl TestConnection { - pub(super) fn new(source: u64, destination: u64) -> Self { - TestConnection(source, destination) - } - } - - impl Edge for TestConnection { - fn source(&self) -> u64 { - self.0 - } - - fn destination(&self) -> u64 { - self.1 - } - } -} +mod test_utils; diff --git a/src/graph/creation.rs b/src/graph/creation.rs index 9cbbf6b..ab79232 100644 --- a/src/graph/creation.rs +++ b/src/graph/creation.rs @@ -118,7 +118,7 @@ where mod tests { use super::*; use crate::component_category::BatteryType; - use crate::graph::test_types::{TestComponent, TestConnection}; + use crate::graph::test_utils::{TestComponent, TestConnection}; use crate::ComponentCategory; use crate::InverterType; diff --git a/src/graph/formulas.rs b/src/graph/formulas.rs new file mode 100644 index 0000000..53e0b86 --- /dev/null +++ b/src/graph/formulas.rs @@ -0,0 +1,61 @@ +// 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; + +mod expr; +mod fallback; +mod generators; +mod traversal; + +/// Formulas for various microgrid metrics. +impl ComponentGraph +where + N: Node, + E: Edge, +{ + /// Returns a string representing the consumer formula for the graph. + pub fn consumer_formula(&self) -> Result { + generators::consumer::ConsumerFormulaBuilder::try_new(self)?.build() + } + + /// Returns a string representing the grid formula for the graph. + pub fn grid_formula(&self) -> Result { + generators::grid::GridFormulaBuilder::try_new(self)?.build() + } + + /// Returns a string representing the producer formula for the graph. + pub fn producer_formula(&self) -> Result { + generators::producer::ProducerFormulaBuilder::try_new(self)?.build() + } + + /// Returns a string representing the battery formula for the graph. + pub fn battery_formula(&self, battery_ids: Option>) -> Result { + generators::battery::BatteryFormulaBuilder::try_new(self, battery_ids)?.build() + } + + /// Returns a string representing the CHP formula for the graph. + pub fn chp_formula(&self, chp_ids: Option>) -> Result { + generators::chp::CHPFormulaBuilder::try_new(self, chp_ids)?.build() + } + + /// Returns a string representing the PV formula for the graph. + pub fn pv_formula(&self, pv_inverter_ids: Option>) -> Result { + generators::pv::PVFormulaBuilder::try_new(self, pv_inverter_ids)?.build() + } + + /// Returns a string representing the EV charger formula for the graph. + pub fn ev_charger_formula( + &self, + ev_charger_ids: Option>, + ) -> Result { + generators::ev_charger::EVChargerFormulaBuilder::try_new(self, ev_charger_ids)?.build() + } +} diff --git a/src/graph/formulas/expr.rs b/src/graph/formulas/expr.rs new file mode 100644 index 0000000..112a6e0 --- /dev/null +++ b/src/graph/formulas/expr.rs @@ -0,0 +1,390 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +use crate::Node; + +#[derive(Debug, Clone)] +pub(crate) enum Expr { + /// A negation of an expression. + Neg { param: Box }, + + /// A numeric constant. + Number { value: f64 }, + + /// A reference to a component. + Component { component_id: u64 }, + + /// An addition of multiple expressions. + Add { params: Vec }, + + /// A subtraction of multiple expressions. + Sub { params: Vec }, + + /// A coalesce function. + Coalesce { params: Vec }, + + /// A min function. + Min { params: Vec }, + + /// A max function. + Max { params: Vec }, +} + +impl std::ops::Add for Expr { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + match (self, rhs) { + // -a + -b = -(a + b) + (Self::Neg { param: lhs }, Self::Neg { param: rhs }) => -(*lhs + *rhs), + // -a + b = b - a + // a + -b = a - b + (other, Self::Neg { param }) | (Self::Neg { param }, other) => other - *param, + // (a + b) + (c + d) = a + b + c + d + (Self::Add { params: mut lhs }, Self::Add { params: mut rhs }) => { + lhs.append(&mut rhs); + Self::Add { params: lhs } + } + // (a + b) + c = a + b + c + (Self::Add { mut params }, rhs) => { + params.push(rhs); + Self::Add { params } + } + // a + (b + c) = a + b + c + (lhs, Self::Add { mut params }) => { + params.insert(0, lhs); + Self::Add { params } + } + // Catch all other cases + (lhs, rhs) => Self::Add { + params: vec![lhs, rhs], + }, + } + } +} + +impl std::ops::Sub for Expr { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + match (self, rhs) { + // (a - b) - -c = a - b + c + (sub @ Self::Sub { .. }, Self::Neg { param }) => sub + *param, + // -a - (b - c) = c - b - a + (Self::Neg { param }, sub @ Self::Sub { .. }) => -sub - *param, + // (a - b) - c = a - b - c + (Self::Sub { mut params }, rhs) => { + params.push(rhs); + Self::Sub { params } + } + // -a - -b = b - a + (Self::Neg { param: lhs }, Self::Neg { param: rhs }) => Self::Sub { + params: vec![*rhs, *lhs], + }, + // -a - b = -(a + b) + (Self::Neg { param }, value) => -(*param + value), + // a - -b = a + b + (lhs, Self::Neg { param }) => lhs + *param, + // Catch all other cases + (lhs, rhs) => Self::Sub { + params: vec![lhs, rhs], + }, + } + } +} + +impl std::ops::Neg for Expr { + type Output = Self; + + fn neg(self) -> Self { + match self { + // -(-a) = a + Expr::Neg { param: inner } => *inner, + // -(a - b) = b - a + // -(a - b - c) = b + c - a + Expr::Sub { mut params } => { + let first = params.remove(0); + Expr::Add { params } - first + } + // Catch all other cases + _ => Expr::Neg { + param: Box::new(self), + }, + } + } +} + +impl From<&N> for Expr { + fn from(node: &N) -> Self { + Self::Component { + component_id: node.component_id(), + } + } +} + +/// Constructors for `FormulaExpression`. +impl Expr { + pub(crate) fn number(value: f64) -> Self { + Self::Number { value } + } + + pub(crate) fn component(component_id: u64) -> Self { + Self::Component { component_id } + } + + pub(crate) fn coalesce(params: Vec) -> Self { + Self::Coalesce { params } + } + + pub(crate) fn min(params: Vec) -> Self { + Self::Min { params } + } + + pub(crate) fn max(params: Vec) -> Self { + Self::Max { params } + } +} + +impl std::fmt::Display for Expr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.generate_string(false)) + } +} + +/// Specifies how to add brackets when generating a string representation of an +/// expression. +/// +/// - `First`: Add brackets around the first component. +/// - `Rest`: Add brackets around all components except the first. +/// - `All`: Add brackets around all components. +/// - `None`: Do not add brackets. +#[derive(PartialEq)] +enum BracketComponents { + First, + Rest, + All, + None, +} + +/// Display helpers for `FormulaExpression`. +impl Expr { + /// Generates a string representation of the expression. + /// + /// If `bracket_whole` is `true` and the expression has more than one + /// component, the whole expression is enclosed in brackets. + fn generate_string(&self, bracket_whole: bool) -> String { + match self { + Self::Neg { param } => format!("-{}", param.generate_string(true)), + Self::Number { value } => { + if value.fract() == 0.0 { + // For whole numbers, format with one decimal place. + format!("{:.1}", value) + } else { + // else format normally. + format!("{}", value) + } + } + Self::Component { component_id } => format!("#{}", component_id), + Self::Add { params } => { + Self::join_params(params, " + ", None, BracketComponents::None, bracket_whole) + } + Self::Sub { params } => { + Self::join_params(params, " - ", None, BracketComponents::Rest, bracket_whole) + } + Self::Coalesce { params } => Self::join_params( + params, + ", ", + Some("COALESCE"), + BracketComponents::None, + false, + ), + Self::Min { params } => { + Self::join_params(params, ", ", Some("MIN"), BracketComponents::None, false) + } + Self::Max { params } => { + Self::join_params(params, ", ", Some("MAX"), BracketComponents::None, false) + } + } + } + + /// Joins a list of expressions into a string, with the specified separator. + /// + /// It also takes an optional prefix, and specifics on how to bracket the + /// components and the whole expression. + fn join_params( + params: &[Expr], + separator: &str, + prefix: Option<&str>, + bracket_components: BracketComponents, + bracket_whole: bool, + ) -> String { + let (mut result, suffix) = match prefix { + Some(prefix) => (format!("{}(", prefix), String::from(")")), + None => (String::new(), String::new()), + }; + let mut num_components = 0; + for expression in params.iter() { + if num_components > 0 { + result.push_str(separator); + } + if (bracket_components == BracketComponents::First && num_components == 0) + || (bracket_components == BracketComponents::Rest && num_components > 0) + || (bracket_components == BracketComponents::All) + { + result.push_str(&expression.generate_string(true)); + } else { + result.push_str(&expression.generate_string(false)); + } + num_components += 1; + } + if bracket_whole && num_components > 1 { + String::from("(") + &result + &suffix + ")" + } else { + result + &suffix + } + } +} + +#[cfg(test)] +mod tests { + use super::Expr; + + #[track_caller] + fn assert_expr(exprs: &[Expr], expected: &str) { + for expr in exprs { + assert_eq!(expr.to_string(), expected); + } + } + + #[test] + fn test_arithmatic() { + let comp = Expr::component; + + assert_expr( + &[ + comp(10) + comp(11) + comp(12) + comp(13), + comp(10) - -comp(11) + (comp(12) + comp(13)), + (comp(10) + comp(11)) - -(comp(12) - -comp(13)), + ], + "#10 + #11 + #12 + #13", + ); + + assert_expr( + &[ + -(comp(10) + comp(11) + comp(12)), + -comp(10) - comp(11) - comp(12), + -comp(10) - (comp(11) + comp(12)), + -(comp(10) + comp(11)) - comp(12), + ], + "-(#10 + #11 + #12)", + ); + + assert_expr( + &[ + comp(11) - comp(10), + comp(11) + -comp(10), + -comp(10) + comp(11), + -comp(10) - -comp(11), + ], + "#11 - #10", + ); + + assert_expr( + &[ + (comp(11) + comp(12)) - comp(10), + (comp(11) + comp(12)) + -comp(10), + -comp(10) + (comp(11) + comp(12)), + -comp(10) - -(comp(11) + comp(12)), + ], + "#11 + #12 - #10", + ); + + assert_expr( + &[ + (comp(11) - comp(12)) - comp(10), + (comp(11) - comp(12)) + -comp(10), + -comp(10) + (comp(11) - comp(12)), + -comp(10) - -(comp(11) - comp(12)), + ], + "#11 - #12 - #10", + ); + + assert_expr( + &[ + comp(11) - comp(12) + comp(10), + (comp(11) - comp(12)) - -comp(10), + (comp(11) - comp(12)) + comp(10), + -(comp(12) - comp(11)) + comp(10), + ], + "#11 - #12 + #10", + ); + + assert_expr( + &[ + (comp(11) + comp(12)) - (comp(10) + comp(13)), + (comp(11) + comp(12)) + -(comp(10) + comp(13)), + -(comp(10) + comp(13)) + (comp(11) + comp(12)), + -(comp(10) + comp(13)) - -(comp(11) + comp(12)), + ], + "#11 + #12 - (#10 + #13)", + ); + + assert_expr( + &[ + (comp(11) - comp(12)) - (comp(10) + comp(13)), + (comp(11) - comp(12)) + -(comp(10) + comp(13)), + -(comp(10) + comp(13)) + (comp(11) - comp(12)), + -(comp(10) + comp(13)) - -(comp(11) - comp(12)), + ], + "#11 - #12 - (#10 + #13)", + ); + + assert_expr( + &[(comp(11) + comp(12)) - (comp(10) - comp(13))], + "#11 + #12 - (#10 - #13)", + ); + assert_expr( + &[(comp(11) + comp(12)) + -(comp(10) - comp(13))], + "#11 + #12 + #13 - #10", + ); + assert_expr( + &[ + -(comp(10) - comp(13)) + (comp(11) + comp(12)), + -(comp(10) - comp(13)) - -(comp(11) + comp(12)), + ], + "#13 - #10 + #11 + #12", + ); + } + + #[test] + fn test_functions() { + let comp = Expr::component; + let coalesce = Expr::coalesce; + let number = Expr::number; + let min = Expr::min; + let max = Expr::max; + + assert_expr( + &[comp(1) + - (coalesce(vec![comp(5), comp(7) + comp(6)]) + coalesce(vec![comp(2), comp(3)])) + + coalesce(vec![ + max(vec![number(0.0), comp(5)]), + max(vec![number(0.0), comp(7)]) + max(vec![number(0.0), comp(6)]), + ])], + concat!( + "#1 - (COALESCE(#5, #7 + #6) + COALESCE(#2, #3)) + ", + "COALESCE(MAX(0.0, #5), MAX(0.0, #7) + MAX(0.0, #6))" + ), + ); + + assert_expr( + &[min(vec![number(0.0), comp(5), comp(7) + comp(6)]) + - max(vec![ + coalesce(vec![comp(5), comp(7) + comp(6)]), + comp(7), + number(22.44), + ])], + "MIN(0.0, #5, #7 + #6) - MAX(COALESCE(#5, #7 + #6), #7, 22.44)", + ) + } +} diff --git a/src/graph/formulas/fallback.rs b/src/graph/formulas/fallback.rs new file mode 100644 index 0000000..a0c184d --- /dev/null +++ b/src/graph/formulas/fallback.rs @@ -0,0 +1,270 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! Fallback expression generator for components and meters. + +use crate::component_category::CategoryPredicates; +use crate::{ComponentGraph, Edge, Error, Node}; +use std::collections::BTreeSet; + +use super::expr::Expr; + +impl ComponentGraph +where + N: Node, + E: Edge, +{ + /// Returns a formula expression with fallbacks where possible for the `sum` + /// of the given component ids. + pub(super) fn fallback_expr( + &self, + component_ids: impl IntoIterator, + prefer_meters: bool, + ) -> Result { + FallbackExpr { + prefer_meters, + graph: self, + } + .generate(BTreeSet::from_iter(component_ids)) + } +} + +struct FallbackExpr<'a, N, E> +where + N: Node, + E: Edge, +{ + pub(crate) prefer_meters: bool, + pub(crate) graph: &'a ComponentGraph, +} + +impl FallbackExpr<'_, N, E> +where + N: Node, + E: Edge, +{ + fn generate(&self, mut component_ids: BTreeSet) -> Result { + let mut formula = None::; + while let Some(component_id) = component_ids.pop_first() { + if let Some(expr) = self.meter_fallback(component_id)? { + formula = Self::add_to_option(formula, expr); + } else if let Some(expr) = self.component_fallback(&mut component_ids, component_id)? { + formula = Self::add_to_option(formula, expr); + } else { + formula = Self::add_to_option(formula, Expr::component(component_id)); + } + } + + formula.ok_or(Error::internal("Search for fallback components failed.")) + } + + /// Returns a fallback expression for a meter component. + fn meter_fallback(&self, component_id: u64) -> Result, Error> { + let component = self.graph.component(component_id)?; + if !component.is_meter() || self.graph.has_meter_successors(component_id)? { + return Ok(None); + } + + if !self.graph.has_successors(component_id)? { + return Ok(Some(Expr::component(component_id))); + } + + let (sum_of_successors, sum_of_coalesced_successors) = self + .graph + .successors(component_id)? + .map(|node| { + ( + Expr::from(node), + Expr::coalesce(vec![Expr::from(node), Expr::number(0.0)]), + ) + }) + .reduce(|a, b| (a.0 + b.0, a.1 + b.1)) + .ok_or(Error::internal( + "Can't find successors of components with successors.", + ))?; + + let has_multiple_successors = matches!(sum_of_successors, Expr::Add { .. }); + + let mut to_be_coalesced: Vec = vec![]; + + if !self.prefer_meters { + to_be_coalesced.push(sum_of_successors.clone()); + } + to_be_coalesced.push(Expr::component(component_id)); + + if self.prefer_meters { + if has_multiple_successors { + to_be_coalesced.push(sum_of_coalesced_successors); + } else { + to_be_coalesced.push(sum_of_successors); + to_be_coalesced.push(Expr::number(0.0)); + } + } else if has_multiple_successors { + to_be_coalesced.push(sum_of_coalesced_successors); + } else { + to_be_coalesced.push(Expr::number(0.0)); + } + + Ok(Some(Expr::coalesce(to_be_coalesced))) + } + + /// Returns a fallback expression for components with the following categories: + /// + /// - CHP + /// - Battery Inverter + /// - PV Inverter + /// - EV Charger + fn component_fallback( + &self, + component_ids: &mut BTreeSet, + component_id: u64, + ) -> Result, Error> { + let component = self.graph.component(component_id)?; + if !component.is_battery_inverter() + && !component.is_chp() + && !component.is_pv_inverter() + && !component.is_ev_charger() + { + return Ok(None); + } + + // If predecessors have other successors that are not in the list of + // component ids, the predecessors can't be used as fallback. + let siblings = self + .graph + .siblings_from_predecessors(component_id)? + .filter(|sibling| sibling.component_id() != component_id) + .collect::>(); + if !siblings + .iter() + .all(|sibling| component_ids.contains(&sibling.component_id())) + { + return Ok(Some(Expr::coalesce(vec![ + Expr::component(component_id), + Expr::number(0.0), + ]))); + } + + for sibling in siblings { + component_ids.remove(&sibling.component_id()); + } + + let predecessor_ids: BTreeSet = self + .graph + .predecessors(component_id)? + .map(|x| x.component_id()) + .collect(); + + Ok(Some(self.generate(predecessor_ids)?)) + } + + fn add_to_option(expr: Option, other: Expr) -> Option { + if let Some(expr) = expr { + Some(expr + other) + } else { + Some(other) + } + } +} + +#[cfg(test)] +mod tests { + use crate::{graph::test_utils::ComponentGraphBuilder, Error}; + + #[test] + fn test_meter_fallback() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + // Add a grid meter. + let grid_meter = builder.meter(); + builder.connect(grid, grid_meter); + + // Add a battery meter with one inverter and one battery. + 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()?; + let expr = graph.fallback_expr(vec![1, 2], false)?; + assert_eq!(expr.to_string(), "#1 + COALESCE(#3, #2, 0.0)"); + + let expr = graph.fallback_expr(vec![1, 2], true)?; + assert_eq!(expr.to_string(), "#1 + COALESCE(#2, #3, 0.0)"); + + let expr = graph.fallback_expr(vec![3], true)?; + assert_eq!(expr.to_string(), "COALESCE(#2, #3, 0.0)"); + + // Add a battery meter with three inverter and three batteries + 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()?; + let expr = graph.fallback_expr(vec![3, 5], false)?; + 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 = graph.fallback_expr(vec![2, 5], true)?; + 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 = graph.fallback_expr(vec![2, 6, 7, 8], true)?; + 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 = graph.fallback_expr(vec![2, 7, 8], true)?; + assert_eq!( + expr.to_string(), + "COALESCE(#2, #3, 0.0) + COALESCE(#7, 0.0) + COALESCE(#8, 0.0)" + ); + + 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()?; + let expr = graph.fallback_expr(vec![5, 12], true)?; + 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 = graph.fallback_expr(vec![7, 14], false)?; + assert_eq!(expr.to_string(), "COALESCE(#7, 0.0) + COALESCE(#14, 0.0)"); + + Ok(()) + } +} diff --git a/src/graph/formulas/generators.rs b/src/graph/formulas/generators.rs new file mode 100644 index 0000000..54bfd58 --- /dev/null +++ b/src/graph/formulas/generators.rs @@ -0,0 +1,12 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! Formula generators for standard metrics. + +pub(super) mod battery; +pub(super) mod chp; +pub(super) mod consumer; +pub(super) mod ev_charger; +pub(super) mod grid; +pub(super) mod producer; +pub(super) mod pv; diff --git a/src/graph/formulas/generators/battery.rs b/src/graph/formulas/generators/battery.rs new file mode 100644 index 0000000..eddf836 --- /dev/null +++ b/src/graph/formulas/generators/battery.rs @@ -0,0 +1,249 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! This module contains the methods for generating producer formulas. + +use std::collections::BTreeSet; + +use crate::component_category::CategoryPredicates; +use crate::{ComponentGraph, Edge, Error, Node}; + +pub(crate) struct BatteryFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + graph: &'a ComponentGraph, + inverter_ids: BTreeSet, +} + +impl<'a, N, E> BatteryFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + pub fn try_new( + graph: &'a ComponentGraph, + battery_ids: Option>, + ) -> Result { + let inverter_ids = if let Some(battery_ids) = battery_ids { + Self::find_inverter_ids(graph, &battery_ids)? + } else { + graph.find_all( + graph.root_id, + |node| node.is_battery_inverter(), + petgraph::Direction::Outgoing, + false, + )? + }; + Ok(Self { + graph, + inverter_ids, + }) + } + + /// Generates the battery formula. + /// + /// This is the sum of all battery_inverters in the graph. If the + /// battery_ids are provided, only the batteries with the given ids are + /// included in the formula. + pub fn build(self) -> Result { + if self.inverter_ids.is_empty() { + return Ok("0.0".to_string()); + } + + self.graph + .fallback_expr(self.inverter_ids, false) + .map(|expr| expr.to_string()) + } + + fn find_inverter_ids( + graph: &ComponentGraph, + battery_ids: &BTreeSet, + ) -> Result, Error> { + let mut inverter_ids = BTreeSet::new(); + for battery_id in battery_ids { + if !graph.component(*battery_id)?.is_battery() { + return Err(Error::invalid_component(format!( + "Component with id {} is not a battery.", + battery_id + ))); + } + for sibling in graph.siblings_from_predecessors(*battery_id)? { + if !battery_ids.contains(&sibling.component_id()) { + return Err(Error::invalid_component(format!( + "Battery {} can't be in a formula without all its siblings: {:?}.", + battery_id, + graph + .siblings_from_predecessors(*battery_id)? + .map(|x| x.component_id()) + .collect::>() + ))); + } + } + inverter_ids.extend(graph.predecessors(*battery_id)?.map(|x| x.component_id())); + } + Ok(inverter_ids) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use crate::{graph::test_utils::ComponentGraphBuilder, Error}; + + #[test] + fn test_battery_formula() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + let grid_meter = builder.meter(); + builder.connect(grid, grid_meter); + + let graph = builder.build()?; + let formula = graph.battery_formula(None)?; + assert_eq!(formula, "0.0"); + + // Add a battery meter with one inverter and one battery. + 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()?; + let formula = graph.battery_formula(None)?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0)"); + + // Add a second battery meter with one inverter and two batteries. + let meter_bat_chain = builder.meter_bat_chain(1, 2); + builder.connect(grid_meter, meter_bat_chain); + + assert_eq!(meter_bat_chain.component_id(), 5); + + let graph = builder.build()?; + let formula = graph.battery_formula(None)?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0) + COALESCE(#6, #5, 0.0)"); + + let formula = graph.battery_formula(Some(BTreeSet::from([4])))?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0)"); + + let formula = graph.battery_formula(Some(BTreeSet::from([7, 8])))?; + assert_eq!(formula, "COALESCE(#6, #5, 0.0)"); + + let formula = graph + .battery_formula(Some(BTreeSet::from([4, 8, 7]))) + .unwrap(); + assert_eq!(formula, "COALESCE(#3, #2, 0.0) + COALESCE(#6, #5, 0.0)"); + + // Add a third battery meter with two inverters with two connected batteries. + let meter_bat_chain = builder.meter_bat_chain(2, 2); + builder.connect(grid_meter, meter_bat_chain); + + assert_eq!(meter_bat_chain.component_id(), 9); + + let graph = builder.build()?; + let formula = graph.battery_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#6, #5, 0.0) + ", + "COALESCE(#11 + #10, #9, COALESCE(#11, 0.0) + COALESCE(#10, 0.0))" + ) + ); + + let formula = graph + .battery_formula(Some(BTreeSet::from([12, 13]))) + .unwrap(); + assert_eq!( + formula, + "COALESCE(#11 + #10, #9, COALESCE(#11, 0.0) + COALESCE(#10, 0.0))" + ); + + // add a PV meter with two PV inverters. + let meter_pv_chain = builder.meter_pv_chain(2); + builder.connect(grid_meter, meter_pv_chain); + + assert_eq!(meter_pv_chain.component_id(), 14); + + let graph = builder.build()?; + let formula = graph.battery_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#6, #5, 0.0) + ", + "COALESCE(#11 + #10, #9, COALESCE(#11, 0.0) + COALESCE(#10, 0.0))" + ) + ); + + // add a battery meter with two inverters that have their own batteries. + let meter = builder.meter(); + builder.connect(grid, meter); + let inv_bat_chain = builder.inv_bat_chain(1); + builder.connect(meter, inv_bat_chain); + + assert_eq!(meter.component_id(), 17); + assert_eq!(inv_bat_chain.component_id(), 18); + + let inv_bat_chain = builder.inv_bat_chain(1); + builder.connect(meter, inv_bat_chain); + + assert_eq!(inv_bat_chain.component_id(), 20); + + let graph = builder.build()?; + let formula = graph.battery_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#6, #5, 0.0) + ", + "COALESCE(#11 + #10, #9, COALESCE(#11, 0.0) + COALESCE(#10, 0.0)) + ", + "COALESCE(#20 + #18, #17, COALESCE(#20, 0.0) + COALESCE(#18, 0.0))" + ) + ); + + let formula = graph + .battery_formula(Some(BTreeSet::from([19, 21]))) + .unwrap(); + assert_eq!( + formula, + "COALESCE(#20 + #18, #17, COALESCE(#20, 0.0) + COALESCE(#18, 0.0))" + ); + + let formula = graph.battery_formula(Some(BTreeSet::from([19]))).unwrap(); + assert_eq!(formula, "COALESCE(#18, 0.0)"); + + let formula = graph.battery_formula(Some(BTreeSet::from([21]))).unwrap(); + assert_eq!(formula, "COALESCE(#20, 0.0)"); + + let formula = graph + .battery_formula(Some(BTreeSet::from([4, 12, 13, 19]))) + .unwrap(); + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#11 + #10, #9, COALESCE(#11, 0.0) + COALESCE(#10, 0.0)) + ", + "COALESCE(#18, 0.0)" + ) + ); + + // Failure cases: + let formula = graph.battery_formula(Some(BTreeSet::from([17]))); + assert_eq!( + formula.unwrap_err().to_string(), + "InvalidComponent: Component with id 17 is not a battery." + ); + + let formula = graph.battery_formula(Some(BTreeSet::from([12]))); + assert_eq!( + formula.unwrap_err().to_string(), + "InvalidComponent: Battery 12 can't be in a formula without all its siblings: [13]." + ); + + Ok(()) + } +} diff --git a/src/graph/formulas/generators/chp.rs b/src/graph/formulas/generators/chp.rs new file mode 100644 index 0000000..098468f --- /dev/null +++ b/src/graph/formulas/generators/chp.rs @@ -0,0 +1,185 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! This module contains the methods for generating producer formulas. + +use std::collections::BTreeSet; + +use crate::component_category::CategoryPredicates; +use crate::{ComponentGraph, Edge, Error, Node}; + +pub(crate) struct CHPFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + graph: &'a ComponentGraph, + chp_ids: BTreeSet, +} + +impl<'a, N, E> CHPFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + pub fn try_new( + graph: &'a ComponentGraph, + chp_ids: Option>, + ) -> Result { + let chp_ids = if let Some(chp_ids) = chp_ids { + chp_ids + } else { + graph.find_all( + graph.root_id, + |node| node.is_chp(), + petgraph::Direction::Outgoing, + false, + )? + }; + Ok(Self { graph, chp_ids }) + } + + /// Generates the chp formula. + /// + /// This is the sum of all CHPs in the graph. If the chp_ids are provided, + /// only the CHPs with the given ids are included in the formula. + pub fn build(self) -> Result { + if self.chp_ids.is_empty() { + return Ok("0.0".to_string()); + } + + for id in &self.chp_ids { + if !self.graph.component(*id)?.is_chp() { + return Err(Error::invalid_component(format!( + "Component with id {} is not a CHP.", + id + ))); + } + } + + self.graph + .fallback_expr(self.chp_ids, false) + .map(|expr| expr.to_string()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use crate::{graph::test_utils::ComponentGraphBuilder, Error}; + + #[test] + fn test_chp_formula() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + let grid_meter = builder.meter(); + builder.connect(grid, grid_meter); + + let graph = builder.build()?; + let formula = graph.chp_formula(None)?; + assert_eq!(formula, "0.0"); + + // Add a chp meter with one chp + let meter_chp_chain = builder.meter_chp_chain(1); + builder.connect(grid_meter, meter_chp_chain); + + assert_eq!(grid_meter.component_id(), 1); + assert_eq!(meter_chp_chain.component_id(), 2); + + let graph = builder.build()?; + let formula = graph.chp_formula(None)?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0)"); + + // Add a battery meter with one inverter and two batteries. + let meter_bat_chain = builder.meter_bat_chain(1, 2); + builder.connect(grid_meter, meter_bat_chain); + + assert_eq!(meter_bat_chain.component_id(), 4); + + let graph = builder.build()?; + let formula = graph.chp_formula(None)?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0)"); + + // Add a chp meter with two CHPs. + let meter_chp_chain = builder.meter_chp_chain(2); + builder.connect(grid_meter, meter_chp_chain); + + assert_eq!(meter_chp_chain.component_id(), 8); + + let graph = builder.build()?; + let formula = graph.chp_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0))" + ) + ); + + let formula = graph.chp_formula(Some(BTreeSet::from([10, 3]))).unwrap(); + assert_eq!(formula, "COALESCE(#3, #2, 0.0) + COALESCE(#10, 0.0)"); + + // add a meter direct to the grid with three CHPs + let meter_chp_chain = builder.meter_chp_chain(3); + builder.connect(grid, meter_chp_chain); + + assert_eq!(meter_chp_chain.component_id(), 11); + + let graph = builder.build()?; + let formula = graph.chp_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(", + "#14 + #13 + #12, ", + "#11, ", + "COALESCE(#14, 0.0) + COALESCE(#13, 0.0) + COALESCE(#12, 0.0)", + ")" + ), + ); + + let formula = graph + .chp_formula(Some(BTreeSet::from([3, 9, 10, 12, 13]))) + .unwrap(); + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(#12, 0.0) + COALESCE(#13, 0.0)" + ) + ); + + let formula = graph + .chp_formula(Some(BTreeSet::from([3, 9, 10, 12, 13, 14]))) + .unwrap(); + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(", + "#14 + #13 + #12, ", + "#11, ", + "COALESCE(#14, 0.0) + COALESCE(#13, 0.0) + COALESCE(#12, 0.0)", + ")" + ), + ); + + let formula = graph.chp_formula(Some(BTreeSet::from([10, 14]))).unwrap(); + assert_eq!(formula, "COALESCE(#10, 0.0) + COALESCE(#14, 0.0)"); + + // Failure cases: + let formula = graph.chp_formula(Some(BTreeSet::from([8]))); + assert_eq!( + formula.unwrap_err().to_string(), + "InvalidComponent: Component with id 8 is not a CHP." + ); + + Ok(()) + } +} diff --git a/src/graph/formulas/generators/consumer.rs b/src/graph/formulas/generators/consumer.rs new file mode 100644 index 0000000..e441cf7 --- /dev/null +++ b/src/graph/formulas/generators/consumer.rs @@ -0,0 +1,450 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! This module contains the methods for generating consumer formulas. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::super::expr::Expr; +use crate::{component_category::CategoryPredicates, ComponentGraph, Edge, Error, Node}; + +pub(crate) struct ConsumerFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + unvisited_meters: BTreeSet, + graph: &'a ComponentGraph, +} + +impl<'a, N, E> ConsumerFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + pub fn try_new(graph: &'a ComponentGraph) -> Result { + Ok(Self { + unvisited_meters: graph.find_all( + graph.root_id, + |node| node.is_meter(), + petgraph::Direction::Outgoing, + true, + )?, + graph, + }) + } + + /// Generates the consumer formula for the given node. + pub fn build(mut self) -> Result { + let mut all_meters = None; + while let Some(meter_id) = self.unvisited_meters.pop_first() { + let consumption = self.component_consumption(meter_id)?; + if let Some(expr) = all_meters { + all_meters = Some(expr + consumption); + } else { + all_meters = Some(consumption); + } + } + + let other_grid_successors = self + .graph + .successors(self.graph.root_id)? + .filter(|s| !s.is_meter() && !s.is_battery_inverter()) + .map(|s| self.component_consumption(s.component_id())) + .reduce(|a, b| Ok(a? + b?)); + + let other_grid_successors = match other_grid_successors { + Some(Ok(expr)) => Some(expr), + Some(Err(err)) => return Err(err), + None => None, + }; + + match (all_meters, other_grid_successors) { + (Some(lhs), Some(rhs)) => Ok((lhs + rhs).to_string()), + (None, Some(expr)) | (Some(expr), None) => Ok(expr.to_string()), + (None, None) => Ok("0.0".to_string()), + } + } + + /// Returns a formula expression for just the consumption part of the given + /// component as a formula expression. + /// + /// This is done by clamping the expression to a maximum of 0.0. + fn max_zero(expr: Expr) -> Expr { + Expr::max(vec![Expr::number(0.0), expr]) + } + + fn component_consumption(&mut self, component_id: u64) -> Result { + let component = self.graph.component(component_id)?; + if component.is_meter() { + self.unvisited_meters.remove(&component_id); + // Create a formula expression from the component. + let mut expr = Expr::from(component); + + // If there are siblings with the same successors as the component, + // then it is a diamond configuration, so we add those siblings to + // the expression. + let mut successors = BTreeMap::from_iter( + self.graph + .successors(component_id)? + .map(|s| (s.component_id(), s)), + ); + for sibling in self.graph.siblings_from_successors(component_id)? { + expr = expr + sibling.into(); + self.unvisited_meters.remove(&sibling.component_id()); + for successor in self.graph.successors(sibling.component_id())? { + successors.insert(successor.component_id(), successor); + } + } + + // Subtract each successor from the expression. + for successor in successors { + let successor_expr = if successor.1.is_meter() { + self.graph.fallback_expr([successor.0], true)? + } else { + Expr::from(successor.1) + }; + expr = expr - successor_expr; + } + + expr = Self::max_zero(expr); + + // If the meter doesn't have any meter successors, its consumption + // can be 0 when it can't be calculated. + if self.graph.has_successors(component_id)? + && !self.graph.has_meter_successors(component_id)? + { + expr = Expr::coalesce(vec![expr, Expr::number(0.0)]); + } + Ok(expr) + } else { + Ok(Self::max_zero(component.into())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::test_utils::ComponentGraphBuilder; + + #[test] + fn test_zero_consumers() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + // Add a battery inverter to the grid, without a battery meter. + let inv_bat_chain = builder.inv_bat_chain(1); + builder.connect(grid, inv_bat_chain); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!(formula, "0.0"); + + Ok(()) + } + + #[test] + fn test_consumer_formula_with_grid_meter() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + // Add a grid meter to the grid, with no successors. + let grid_meter = builder.meter(); + builder.connect(grid, grid_meter); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!(formula, "MAX(0.0, #1)"); + + // Add a battery meter with one battery inverter and one battery to the + // grid meter. + let meter_bat_chain = builder.meter_bat_chain(1, 1); + builder.connect(grid_meter, meter_bat_chain); + + assert_eq!(meter_bat_chain.component_id(), 2); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + // Formula subtracts the battery meter from the grid meter, and the + // battery inverter from the battery meter. + assert_eq!( + formula, + "MAX(0.0, #1 - COALESCE(#2, #3, 0.0)) + COALESCE(MAX(0.0, #2 - #3), 0.0)" + ); + + // Add a solar meter with two solar inverters to the grid meter. + let meter_pv_chain = builder.meter_pv_chain(2); + builder.connect(grid_meter, meter_pv_chain); + + assert_eq!(meter_pv_chain.component_id(), 5); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // difference of grid meter from all its suceessors + "MAX(", + "0.0, ", + "#1 - COALESCE(#2, #3, 0.0) - COALESCE(#5, COALESCE(#7, 0.0) + COALESCE(#6, 0.0))", + ") + ", + // difference of battery meter from battery inverter and pv + // meter from the two pv inverters. + "COALESCE(MAX(0.0, #2 - #3), 0.0) + COALESCE(MAX(0.0, #5 - #6 - #7), 0.0)", + ) + ); + + // Add a "mixed" meter with a CHP, an ev charger and a solar inverter to + // the grid meter. + let solar_inverter = builder.solar_inverter(); + let chp = builder.chp(); + let ev_charger = builder.ev_charger(); + let meter = builder.meter(); + builder.connect(meter, solar_inverter); + builder.connect(meter, chp); + builder.connect(meter, ev_charger); + builder.connect(grid_meter, meter); + + assert_eq!(solar_inverter.component_id(), 8); + assert_eq!(chp.component_id(), 9); + assert_eq!(ev_charger.component_id(), 10); + assert_eq!(meter.component_id(), 11); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // difference of grid meter from all its suceessors + "MAX(0.0, ", + "#1 - ", + "COALESCE(#2, #3, 0.0) - ", + "COALESCE(#5, COALESCE(#7, 0.0) + COALESCE(#6, 0.0)) - ", + "COALESCE(#11, COALESCE(#10, 0.0) + COALESCE(#9, 0.0) + COALESCE(#8, 0.0))", + ") + ", + // difference of battery meter from battery inverter and pv + // meter from the two pv inverters. + "COALESCE(MAX(0.0, #2 - #3), 0.0) + COALESCE(MAX(0.0, #5 - #6 - #7), 0.0) + ", + // difference of "mixed" meter from its successors. + "COALESCE(MAX(0.0, #11 - #8 - #9 - #10), 0.0)" + ) + ); + + // add a battery chain to the grid meter and a dangling meter to the grid. + let meter_bat_chain = builder.meter_bat_chain(1, 1); + let dangling_meter = builder.meter(); + builder.connect(grid_meter, meter_bat_chain); + builder.connect(grid, dangling_meter); + + assert_eq!(meter_bat_chain.component_id(), 12); + assert_eq!(dangling_meter.component_id(), 15); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // difference of grid meter from all its suceessors + "MAX(0.0, ", + "#1 - ", + "COALESCE(#2, #3, 0.0) - ", + "COALESCE(#5, COALESCE(#7, 0.0) + COALESCE(#6, 0.0)) - ", + "COALESCE(#11, COALESCE(#10, 0.0) + COALESCE(#9, 0.0) + COALESCE(#8, 0.0)) - ", + "COALESCE(#12, #13, 0.0)", + ") + ", + // difference of battery meter from battery inverter and pv + // meter from the two pv inverters. + "COALESCE(MAX(0.0, #2 - #3), 0.0) + COALESCE(MAX(0.0, #5 - #6 - #7), 0.0) + ", + // difference of "mixed" meter from its successors. + "COALESCE(MAX(0.0, #11 - #8 - #9 - #10), 0.0) + ", + // difference of second battery meter from inverter. + "COALESCE(MAX(0.0, #12 - #13), 0.0) + ", + // consumption component of the dangling meter. + "MAX(0.0, #15)" + ) + ); + + Ok(()) + } + + #[test] + fn test_consumer_formula_without_grid_meter() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + // Add a meter-inverter-battery chain to the grid component. + let meter_bat_chain = builder.meter_bat_chain(1, 1); + builder.connect(grid, meter_bat_chain); + + assert_eq!(meter_bat_chain.component_id(), 1); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + // Formula subtracts inverter from battery meter, or shows zero + // consumption if either of the components have no data. + assert_eq!(formula, "COALESCE(MAX(0.0, #1 - #2), 0.0)"); + + // Add a pv meter with one solar inverter and two dangling meter. + let meter_pv_chain = builder.meter_pv_chain(1); + let dangling_meter_1 = builder.meter(); + let dangling_meter_2 = builder.meter(); + builder.connect(grid, meter_pv_chain); + builder.connect(grid, dangling_meter_1); + builder.connect(grid, dangling_meter_2); + + assert_eq!(meter_pv_chain.component_id(), 4); + assert_eq!(dangling_meter_1.component_id(), 6); + assert_eq!(dangling_meter_2.component_id(), 7); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // subtract meter successors from meters + "COALESCE(MAX(0.0, #1 - #2), 0.0) + COALESCE(MAX(0.0, #4 - #5), 0.0) + ", + // dangling meters + "MAX(0.0, #6) + MAX(0.0, #7)" + ) + ); + + // Add a battery inverter to the grid, without a battery meter. + // + // This shouldn't show up in the formula, because battery inverter + // consumption is charging, not site consumption. + let inv_bat_chain = builder.inv_bat_chain(1); + builder.connect(grid, inv_bat_chain); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // subtract meter successors from meters + "COALESCE(MAX(0.0, #1 - #2), 0.0) + COALESCE(MAX(0.0, #4 - #5), 0.0) + ", + // dangling meters + "MAX(0.0, #6) + MAX(0.0, #7)" + ) + ); + + // Add a PV inverter and a CHP to the grid, without a meter. + // + // Their consumption is counted as site consumption, because they can't + // be taken out, by discharging the batteries, for example. + let pv_inv = builder.solar_inverter(); + let chp = builder.chp(); + builder.connect(grid, pv_inv); + builder.connect(grid, chp); + + assert_eq!(pv_inv.component_id(), 10); + assert_eq!(chp.component_id(), 11); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // subtract meter successors from meters + "COALESCE(MAX(0.0, #1 - #2), 0.0) + COALESCE(MAX(0.0, #4 - #5), 0.0) + ", + // dangling meters + "MAX(0.0, #6) + MAX(0.0, #7) + ", + // PV inverter and CHP + "MAX(0.0, #11) + MAX(0.0, #10)", + ) + ); + + Ok(()) + } + + #[test] + fn test_consumer_formula_diamond_meters() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + // Add three meters to the grid + let grid_meter_1 = builder.meter(); + let grid_meter_2 = builder.meter(); + let grid_meter_3 = builder.meter(); + builder.connect(grid, grid_meter_1); + builder.connect(grid, grid_meter_2); + builder.connect(grid, grid_meter_3); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!(formula, "MAX(0.0, #1) + MAX(0.0, #2) + MAX(0.0, #3)"); + + // Add two solar inverters with two grid meters as predecessors. + let meter_pv_chain_1 = builder.meter_pv_chain(1); + let meter_pv_chain_2 = builder.meter_pv_chain(1); + builder.connect(grid_meter_1, meter_pv_chain_1); + builder.connect(grid_meter_1, meter_pv_chain_2); + builder.connect(grid_meter_2, meter_pv_chain_1); + builder.connect(grid_meter_2, meter_pv_chain_2); + + assert_eq!(meter_pv_chain_1.component_id(), 4); + assert_eq!(meter_pv_chain_2.component_id(), 6); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // difference of pv powers from first two grid meters + "MAX(0.0, #1 + #2 - COALESCE(#4, #5, 0.0) - COALESCE(#6, #7, 0.0)) + ", + // third grid meter still dangling + "MAX(0.0, #3) + ", + // difference of solar inverters from their meters + "COALESCE(MAX(0.0, #4 - #5), 0.0) + COALESCE(MAX(0.0, #6 - #7), 0.0)" + ) + ); + + // Add a meter to grid meter 3, and then add the two solar inverters to + // that meter. + let meter = builder.meter(); + builder.connect(grid_meter_3, meter); + builder.connect(meter, meter_pv_chain_1); + builder.connect(meter, meter_pv_chain_2); + + assert_eq!(meter.component_id(), 8); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // difference of pv powers from first two grid meters and meter#8 + "MAX(0.0, #1 + #8 + #2 - COALESCE(#4, #5, 0.0) - COALESCE(#6, #7, 0.0)) + ", + // difference of meter#8 from third grid meter + "MAX(0.0, #3 - #8) + ", + // difference of solar inverters from their meters + "COALESCE(MAX(0.0, #4 - #5), 0.0) + COALESCE(MAX(0.0, #6 - #7), 0.0)" + ) + ); + + // Add a battery inverter to the first grid meter. + let meter_bat_chain = builder.meter_bat_chain(1, 1); + builder.connect(grid_meter_1, meter_bat_chain); + + let graph = builder.build()?; + let formula = graph.consumer_formula()?; + assert_eq!( + formula, + concat!( + // difference of pv and battery powers from first two grid + // meters and meter#8 + "MAX(0.0, ", + "#1 + #8 + #2 - COALESCE(#4, #5, 0.0) - COALESCE(#6, #7, 0.0) - COALESCE(#9, #10, 0.0)", + ") + ", + // difference of meter#8 from third grid meter + "MAX(0.0, #3 - #8) + ", + // difference of solar inverters from their meters + "COALESCE(MAX(0.0, #4 - #5), 0.0) + COALESCE(MAX(0.0, #6 - #7), 0.0) + ", + // difference of battery inverter from battery meter + "COALESCE(MAX(0.0, #9 - #10), 0.0)" + ) + ); + + Ok(()) + } +} diff --git a/src/graph/formulas/generators/ev_charger.rs b/src/graph/formulas/generators/ev_charger.rs new file mode 100644 index 0000000..9c0db26 --- /dev/null +++ b/src/graph/formulas/generators/ev_charger.rs @@ -0,0 +1,193 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! This module contains the methods for generating producer formulas. + +use std::collections::BTreeSet; + +use crate::component_category::CategoryPredicates; +use crate::{ComponentGraph, Edge, Error, Node}; + +pub(crate) struct EVChargerFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + graph: &'a ComponentGraph, + ev_charger_ids: BTreeSet, +} + +impl<'a, N, E> EVChargerFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + pub fn try_new( + graph: &'a ComponentGraph, + ev_charger_ids: Option>, + ) -> Result { + let ev_charger_ids = if let Some(ev_charger_ids) = ev_charger_ids { + ev_charger_ids + } else { + graph.find_all( + graph.root_id, + |node| node.is_ev_charger(), + petgraph::Direction::Outgoing, + false, + )? + }; + Ok(Self { + graph, + ev_charger_ids, + }) + } + + /// Generates the EV charger formula. + /// + /// This is the sum of all EV chargers in the graph. If the ev_charger_ids are provided, + /// only the EV chargers with the given ids are included in the formula. + pub fn build(self) -> Result { + if self.ev_charger_ids.is_empty() { + return Ok("0.0".to_string()); + } + + for id in &self.ev_charger_ids { + if !self.graph.component(*id)?.is_ev_charger() { + return Err(Error::invalid_component(format!( + "Component with id {} is not an EV charger.", + id + ))); + } + } + + self.graph + .fallback_expr(self.ev_charger_ids, false) + .map(|expr| expr.to_string()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use crate::{graph::test_utils::ComponentGraphBuilder, Error}; + + #[test] + fn test_ev_charger_formula() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + let grid_meter = builder.meter(); + builder.connect(grid, grid_meter); + + let graph = builder.build()?; + let formula = graph.ev_charger_formula(None)?; + assert_eq!(formula, "0.0"); + + // Add a EV charger meter with one EV charger. + let meter_ev_charger_chain = builder.meter_ev_charger_chain(1); + builder.connect(grid_meter, meter_ev_charger_chain); + + assert_eq!(grid_meter.component_id(), 1); + assert_eq!(meter_ev_charger_chain.component_id(), 2); + + let graph = builder.build()?; + let formula = graph.ev_charger_formula(None)?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0)"); + + // Add a battery meter with one inverter and two batteries. + let meter_bat_chain = builder.meter_bat_chain(1, 2); + builder.connect(grid_meter, meter_bat_chain); + + assert_eq!(meter_bat_chain.component_id(), 4); + + let graph = builder.build()?; + let formula = graph.ev_charger_formula(None)?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0)"); + + // Add a EV charger meter with two EV chargers. + let meter_ev_charger_chain = builder.meter_ev_charger_chain(2); + builder.connect(grid_meter, meter_ev_charger_chain); + + assert_eq!(meter_ev_charger_chain.component_id(), 8); + + let graph = builder.build()?; + let formula = graph.ev_charger_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0))" + ) + ); + + let formula = graph + .ev_charger_formula(Some(BTreeSet::from([10, 3]))) + .unwrap(); + assert_eq!(formula, "COALESCE(#3, #2, 0.0) + COALESCE(#10, 0.0)"); + + // add a meter direct to the grid with three EV chargers + let meter_ev_charger_chain = builder.meter_ev_charger_chain(3); + builder.connect(grid, meter_ev_charger_chain); + + assert_eq!(meter_ev_charger_chain.component_id(), 11); + + let graph = builder.build()?; + let formula = graph.ev_charger_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(", + "#14 + #13 + #12, ", + "#11, ", + "COALESCE(#14, 0.0) + COALESCE(#13, 0.0) + COALESCE(#12, 0.0)", + ")" + ), + ); + + let formula = graph + .ev_charger_formula(Some(BTreeSet::from([3, 9, 10, 12, 13]))) + .unwrap(); + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(#12, 0.0) + ", + "COALESCE(#13, 0.0)" + ) + ); + + let formula = graph + .ev_charger_formula(Some(BTreeSet::from([3, 9, 10, 12, 13, 14]))) + .unwrap(); + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(", + "#14 + #13 + #12, ", + "#11, ", + "COALESCE(#14, 0.0) + COALESCE(#13, 0.0) + COALESCE(#12, 0.0)", + ")" + ) + ); + + let formula = graph + .ev_charger_formula(Some(BTreeSet::from([10, 14]))) + .unwrap(); + assert_eq!(formula, "COALESCE(#10, 0.0) + COALESCE(#14, 0.0)"); + + // Failure cases: + let formula = graph.ev_charger_formula(Some(BTreeSet::from([8]))); + assert_eq!( + formula.unwrap_err().to_string(), + "InvalidComponent: Component with id 8 is not an EV charger." + ); + + Ok(()) + } +} diff --git a/src/graph/formulas/generators/grid.rs b/src/graph/formulas/generators/grid.rs new file mode 100644 index 0000000..803e07d --- /dev/null +++ b/src/graph/formulas/generators/grid.rs @@ -0,0 +1,100 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! This module contains the methods for generating grid formulas. + +use crate::{ComponentGraph, Edge, Error, Node}; + +pub(crate) struct GridFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + graph: &'a ComponentGraph, +} + +impl<'a, N, E> GridFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + pub fn try_new(graph: &'a ComponentGraph) -> Result { + Ok(Self { graph }) + } + + /// Generates the grid formula for the given node. + /// + /// The grid formula is the sum of all components connected to the grid. + /// This formula can be used for calculating power or current metrics at the + /// grid connection point. + pub fn build(self) -> Result { + let mut expr = None; + for comp in self.graph.successors(self.graph.root_id)? { + let comp = self.graph.fallback_expr([comp.component_id()], true)?; + expr = match expr { + None => Some(comp), + Some(e) => Some(comp + e), + }; + } + Ok(expr + .map(|e| e.to_string()) + .unwrap_or_else(|| "0.0".to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::test_utils::ComponentGraphBuilder; + + #[test] + fn test_grid_formula() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + // Add a grid meter and a battery chain behind it. + let grid_meter = builder.meter(); + let meter_bat_chain = builder.meter_bat_chain(1, 1); + builder.connect(grid, grid_meter); + builder.connect(grid_meter, meter_bat_chain); + + let graph = builder.build()?; + let formula = graph.grid_formula()?; + assert_eq!(formula, "#1"); + + // Add an additional dangling meter, and a PV chain and a battery chain + // to the grid + let dangling_meter = builder.meter(); + let meter_bat_chain = builder.meter_bat_chain(1, 1); + let meter_pv_chain = builder.meter_pv_chain(1); + builder.connect(grid, dangling_meter); + builder.connect(grid, meter_bat_chain); + builder.connect(grid, meter_pv_chain); + + assert_eq!(dangling_meter.component_id(), 5); + assert_eq!(meter_bat_chain.component_id(), 6); + assert_eq!(meter_pv_chain.component_id(), 9); + + let graph = builder.build()?; + let formula = graph.grid_formula()?; + assert_eq!( + formula, + "#1 + #5 + COALESCE(#6, #7, 0.0) + COALESCE(#9, #10, 0.0)" + ); + + // Add a PV inverter to the grid, without a meter. + let pv_inverter = builder.solar_inverter(); + builder.connect(grid, pv_inverter); + + assert_eq!(pv_inverter.component_id(), 11); + + let graph = builder.build()?; + let formula = graph.grid_formula()?; + assert_eq!( + formula, + "#1 + #5 + COALESCE(#6, #7, 0.0) + COALESCE(#9, #10, 0.0) + COALESCE(#11, 0.0)" + ); + + Ok(()) + } +} diff --git a/src/graph/formulas/generators/producer.rs b/src/graph/formulas/generators/producer.rs new file mode 100644 index 0000000..5f13379 --- /dev/null +++ b/src/graph/formulas/generators/producer.rs @@ -0,0 +1,179 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! This module contains the methods for generating producer formulas. + +use super::super::expr::Expr; +use crate::component_category::CategoryPredicates; +use crate::{ComponentGraph, Edge, Error, Node}; + +pub(crate) struct ProducerFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + graph: &'a ComponentGraph, +} + +impl<'a, N, E> ProducerFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + pub fn try_new(graph: &'a ComponentGraph) -> Result { + Ok(Self { graph }) + } + + /// Generates the production formula. + /// + /// The production formula is the sum of all the PV and CHP components in + /// the graph. + pub fn build(self) -> Result { + let mut expr = None; + for component_id in self.graph.find_all( + self.graph.root_id, + |node| { + self.graph.is_pv_meter(node.component_id()).unwrap_or(false) + || self + .graph + .is_chp_meter(node.component_id()) + .unwrap_or(false) + || node.is_pv_inverter() + || node.is_chp() + }, + petgraph::Direction::Outgoing, + false, + )? { + let comp_expr = Self::min_zero(self.graph.fallback_expr([component_id], false)?); + expr = match expr { + None => Some(comp_expr), + Some(e) => Some(e + comp_expr), + }; + } + Ok(expr + .map(|e| e.to_string()) + .unwrap_or_else(|| "0.0".to_string())) + } + + /// Returns a formula expression for just the production part of the given + /// component as a formula expression. + /// + /// This is done by clamping the expression to a minimum of 0.0. + fn min_zero(expr: Expr) -> Expr { + Expr::min(vec![Expr::number(0.0), expr]) + } +} + +#[cfg(test)] +mod tests { + use crate::{graph::test_utils::ComponentGraphBuilder, Error}; + + #[test] + fn test_producer_formula() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + // Add a grid meter and a PV meter with two PV inverters behind it. + let grid_meter = builder.meter(); + builder.connect(grid, grid_meter); + + let graph = builder.build()?; + let formula = graph.producer_formula()?; + assert_eq!(formula, "0.0"); + + let meter_pv_chain = builder.meter_pv_chain(2); + builder.connect(grid_meter, meter_pv_chain); + + let graph = builder.build()?; + let formula = graph.producer_formula()?; + assert_eq!( + formula, + "MIN(0.0, COALESCE(#4 + #3, #2, COALESCE(#4, 0.0) + COALESCE(#3, 0.0)))" + ); + + // Add a CHP meter to the grid with a CHP behind it. + let meter_chp_chain = builder.meter_chp_chain(1); + builder.connect(grid, meter_chp_chain); + + let graph = builder.build()?; + let formula = graph.producer_formula()?; + assert_eq!( + formula, + concat!( + "MIN(0.0, COALESCE(#4 + #3, #2, COALESCE(#4, 0.0) + COALESCE(#3, 0.0))) + ", + "MIN(0.0, COALESCE(#6, #5, 0.0))" + ) + ); + + // Add a CHP to the grid, without a meter. + let chp = builder.chp(); + builder.connect(grid, chp); + + let graph = builder.build()?; + let formula = graph.producer_formula()?; + assert_eq!( + formula, + concat!( + "MIN(0.0, COALESCE(#4 + #3, #2, COALESCE(#4, 0.0) + COALESCE(#3, 0.0))) + ", + "MIN(0.0, COALESCE(#6, #5, 0.0)) + ", + "MIN(0.0, COALESCE(#7, 0.0))" + ) + ); + + // Add a PV inverter to the grid_meter. + let pv_inverter = builder.solar_inverter(); + builder.connect(grid_meter, pv_inverter); + + let graph = builder.build()?; + let formula = graph.producer_formula()?; + assert_eq!( + formula, + concat!( + "MIN(0.0, COALESCE(#4 + #3, #2, COALESCE(#4, 0.0) + COALESCE(#3, 0.0))) + ", + "MIN(0.0, COALESCE(#6, #5, 0.0)) + ", + "MIN(0.0, COALESCE(#7, 0.0)) + ", + "MIN(0.0, COALESCE(#8, 0.0))" + ) + ); + + // Add a battery chain to the grid meter. + let meter_bat_chain = builder.meter_bat_chain(1, 1); + builder.connect(grid_meter, meter_bat_chain); + + let graph = builder.build()?; + let formula = graph.producer_formula()?; + assert_eq!( + formula, + concat!( + "MIN(0.0, COALESCE(#4 + #3, #2, COALESCE(#4, 0.0) + COALESCE(#3, 0.0))) + ", + "MIN(0.0, COALESCE(#6, #5, 0.0)) + ", + "MIN(0.0, COALESCE(#7, 0.0)) + ", + "MIN(0.0, COALESCE(#8, 0.0))" + ) + ); + + // Add a meter to the grid meter, that has a PV inverter and a CHP behind it. + let meter = builder.meter(); + let pv_inverter = builder.solar_inverter(); + let chp = builder.chp(); + builder.connect(meter, pv_inverter); + builder.connect(meter, chp); + builder.connect(grid_meter, meter); + + let graph = builder.build()?; + let formula = graph.producer_formula()?; + assert_eq!( + formula, + concat!( + "MIN(0.0, COALESCE(#4 + #3, #2, COALESCE(#4, 0.0) + COALESCE(#3, 0.0))) + ", + "MIN(0.0, COALESCE(#6, #5, 0.0)) + ", + "MIN(0.0, COALESCE(#7, 0.0)) + ", + "MIN(0.0, COALESCE(#8, 0.0)) + ", + "MIN(0.0, COALESCE(#13, 0.0)) + ", + "MIN(0.0, COALESCE(#14, 0.0))" + ) + ); + + Ok(()) + } +} diff --git a/src/graph/formulas/generators/pv.rs b/src/graph/formulas/generators/pv.rs new file mode 100644 index 0000000..ec5e5ab --- /dev/null +++ b/src/graph/formulas/generators/pv.rs @@ -0,0 +1,189 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! This module contains the methods for generating producer formulas. + +use std::collections::BTreeSet; + +use crate::component_category::CategoryPredicates; +use crate::{ComponentGraph, Edge, Error, Node}; + +pub(crate) struct PVFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + graph: &'a ComponentGraph, + pv_inverter_ids: BTreeSet, +} + +impl<'a, N, E> PVFormulaBuilder<'a, N, E> +where + N: Node, + E: Edge, +{ + pub fn try_new( + graph: &'a ComponentGraph, + pv_inverter_ids: Option>, + ) -> Result { + let pv_inverter_ids = if let Some(pv_inverter_ids) = pv_inverter_ids { + pv_inverter_ids + } else { + graph.find_all( + graph.root_id, + |node| node.is_pv_inverter(), + petgraph::Direction::Outgoing, + false, + )? + }; + Ok(Self { + graph, + pv_inverter_ids, + }) + } + + /// Generates the PV formula. + /// + /// This is the sum of all PV inverters in the graph. If the pv_inverter_ids are provided, + /// only the PV inverters with the given ids are included in the formula. + pub fn build(self) -> Result { + if self.pv_inverter_ids.is_empty() { + return Ok("0.0".to_string()); + } + + for id in &self.pv_inverter_ids { + if !self.graph.component(*id)?.is_pv_inverter() { + return Err(Error::invalid_component(format!( + "Component with id {} is not a PV inverter.", + id + ))); + } + } + + self.graph + .fallback_expr(self.pv_inverter_ids, false) + .map(|expr| expr.to_string()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use crate::{graph::test_utils::ComponentGraphBuilder, Error}; + + #[test] + fn test_pv_formula() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + let grid_meter = builder.meter(); + builder.connect(grid, grid_meter); + + let graph = builder.build()?; + let formula = graph.pv_formula(None)?; + assert_eq!(formula, "0.0"); + + // Add a PV meter with one PV inverter. + let meter_pv_chain = builder.meter_pv_chain(1); + builder.connect(grid_meter, meter_pv_chain); + + assert_eq!(grid_meter.component_id(), 1); + assert_eq!(meter_pv_chain.component_id(), 2); + + let graph = builder.build()?; + let formula = graph.pv_formula(None)?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0)"); + + // Add a battery meter with one inverter and two batteries. + let meter_bat_chain = builder.meter_bat_chain(1, 2); + builder.connect(grid_meter, meter_bat_chain); + + assert_eq!(meter_bat_chain.component_id(), 4); + + let graph = builder.build()?; + let formula = graph.pv_formula(None)?; + assert_eq!(formula, "COALESCE(#3, #2, 0.0)"); + + // Add a PV meter with two PV inverters. + let meter_pv_chain = builder.meter_pv_chain(2); + builder.connect(grid_meter, meter_pv_chain); + + assert_eq!(meter_pv_chain.component_id(), 8); + + let graph = builder.build()?; + let formula = graph.pv_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0))" + ) + ); + + let formula = graph.pv_formula(Some(BTreeSet::from([10, 3]))).unwrap(); + assert_eq!(formula, "COALESCE(#3, #2, 0.0) + COALESCE(#10, 0.0)"); + + // add a meter direct to the grid with three PV inverters + let meter_pv_chain = builder.meter_pv_chain(3); + builder.connect(grid, meter_pv_chain); + + assert_eq!(meter_pv_chain.component_id(), 11); + + let graph = builder.build()?; + let formula = graph.pv_formula(None)?; + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(", + "#14 + #13 + #12, ", + "#11, ", + "COALESCE(#14, 0.0) + COALESCE(#13, 0.0) + COALESCE(#12, 0.0)", + ")" + ), + ); + + let formula = graph + .pv_formula(Some(BTreeSet::from([3, 9, 10, 12, 13]))) + .unwrap(); + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(#12, 0.0) + ", + "COALESCE(#13, 0.0)" + ) + ); + + let formula = graph + .pv_formula(Some(BTreeSet::from([3, 9, 10, 12, 13, 14]))) + .unwrap(); + assert_eq!( + formula, + concat!( + "COALESCE(#3, #2, 0.0) + ", + "COALESCE(#10 + #9, #8, COALESCE(#10, 0.0) + COALESCE(#9, 0.0)) + ", + "COALESCE(", + "#14 + #13 + #12, ", + "#11, ", + "COALESCE(#14, 0.0) + COALESCE(#13, 0.0) + COALESCE(#12, 0.0)", + ")" + ) + ); + + let formula = graph.pv_formula(Some(BTreeSet::from([10, 14]))).unwrap(); + assert_eq!(formula, "COALESCE(#10, 0.0) + COALESCE(#14, 0.0)"); + + // Failure cases: + let formula = graph.pv_formula(Some(BTreeSet::from([8]))); + assert_eq!( + formula.unwrap_err().to_string(), + "InvalidComponent: Component with id 8 is not a PV inverter." + ); + + Ok(()) + } +} diff --git a/src/graph/formulas/traversal.rs b/src/graph/formulas/traversal.rs new file mode 100644 index 0000000..7f04c69 --- /dev/null +++ b/src/graph/formulas/traversal.rs @@ -0,0 +1,24 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +use crate::{component_category::CategoryPredicates, ComponentGraph, Edge, Error, Node}; + +impl ComponentGraph +where + N: Node, + E: Edge, +{ + /// Returns `true` if the given component has any successors. + pub(super) fn has_successors(&self, component_id: u64) -> Result { + Ok(self.successors(component_id)?.next().is_some()) + } + + /// Returns `true` if the given component has any meter successors. + pub(super) fn has_meter_successors(&self, component_id: u64) -> Result { + let mut has_successors = false; + Ok(self.successors(component_id)?.any(|x| { + has_successors = true; + x.is_meter() + }) && has_successors) + } +} diff --git a/src/graph/iterators.rs b/src/graph/iterators.rs index 0d2fe8d..6fecb3c 100644 --- a/src/graph/iterators.rs +++ b/src/graph/iterators.rs @@ -3,6 +3,8 @@ //! Iterators over components and connections in a `ComponentGraph`. +use std::{collections::HashSet, iter::Flatten, vec::IntoIter}; + use petgraph::graph::DiGraph; use crate::{ComponentGraph, Edge, Node}; @@ -69,3 +71,43 @@ where self.iter.next().map(|i| &self.graph[i]) } } + +/// An iterator over the siblings of a component in a `ComponentGraph`. +pub struct Siblings<'a, N> +where + N: Node, +{ + pub(crate) component_id: u64, + pub(crate) iter: Flatten>>, + visited: HashSet, +} + +impl<'a, N> Siblings<'a, N> +where + N: Node, +{ + pub(crate) fn new(component_id: u64, iter: Flatten>>) -> Self { + Siblings { + component_id, + iter, + visited: HashSet::new(), + } + } +} + +impl<'a, N> Iterator for Siblings<'a, N> +where + N: Node, +{ + type Item = &'a N; + + fn next(&mut self) -> Option { + for i in self.iter.by_ref() { + if i.component_id() == self.component_id || !self.visited.insert(i.component_id()) { + continue; + } + return Some(i); + } + None + } +} diff --git a/src/graph/meter_roles.rs b/src/graph/meter_roles.rs index e541a8c..046797f 100644 --- a/src/graph/meter_roles.rs +++ b/src/graph/meter_roles.rs @@ -11,62 +11,6 @@ where N: Node, E: Edge, { - /// Returns true if a node is a grid meter. - /// - /// A meter is identified as a grid meter if: - /// - it is a successor of the grid component, - /// - all its siblings are meters, - /// - if there are siblings, the successors of it and the successors of - /// its siblings are meters. - pub fn is_grid_meter(&self, component_id: u64) -> Result { - let component = self.component(component_id)?; - - // Component must be a meter. - if !component.is_meter() { - return Ok(false); - } - - let mut predecessors = self.predecessors(component_id)?; - - // The meter must have a grid as a predecessor. - let Some(grid) = predecessors.next() else { - return Ok(false); - }; - - let has_multiple_predecessors = predecessors.next().is_some(); - - if !grid.is_grid() || has_multiple_predecessors { - return Ok(false); - } - - // All siblings must be meters. - let mut num_grid_successors = 0; - let mut non_meter_successors = false; - for grid_successor in self.successors(grid.component_id())? { - if grid_successor.is_meter() { - num_grid_successors += 1; - } else { - return Ok(false); - } - let mut successors = self.successors(grid_successor.component_id())?; - if successors.any(|n| !n.is_meter()) { - non_meter_successors = true; - } - } - - // If there are no siblings, the meter is a grid meter. - if num_grid_successors == 1 { - return Ok(true); - } - - // If there are siblings, the meter is a grid meter if the successors of - // it and the successors of the siblings are meters. - if non_meter_successors { - return Ok(false); - } - Ok(true) - } - /// Returns true if the node is a PV meter. /// /// A meter is identified as a PV meter if: @@ -126,6 +70,20 @@ where }) && has_successors) } + + /// Returns true if the node is a component meter. + /// + /// A meter is a component meter if it is one of the following: + /// - a PV meter, + /// - a battery meter, + /// - an EV charger meter, + /// - a CHP meter. + pub fn is_component_meter(&self, component_id: u64) -> Result { + Ok(self.is_pv_meter(component_id)? + || self.is_battery_meter(component_id)? + || self.is_ev_charger_meter(component_id)? + || self.is_chp_meter(component_id)?) + } } #[cfg(test)] @@ -134,7 +92,7 @@ mod tests { use crate::component_category::BatteryType; use crate::component_category::EvChargerType; use crate::error::Error; - use crate::graph::test_types::{TestComponent, TestConnection}; + use crate::graph::test_utils::{TestComponent, TestConnection}; use crate::ComponentCategory; use crate::InverterType; @@ -259,29 +217,6 @@ mod tests { Ok(found_meters) } - #[test] - fn test_is_grid_meter() -> Result<(), Error> { - let (components, connections) = nodes_and_edges(); - assert_eq!( - find_matching_components(components, connections, ComponentGraph::is_grid_meter)?, - vec![2], - ); - - let (components, connections) = with_multiple_grid_meters(); - assert_eq!( - find_matching_components(components, connections, ComponentGraph::is_grid_meter)?, - vec![2, 19, 20], - ); - - let (components, connections) = without_grid_meters(); - assert_eq!( - find_matching_components(components, connections, ComponentGraph::is_grid_meter)?, - vec![], - ); - - Ok(()) - } - #[test] fn test_is_pv_meter() -> Result<(), Error> { let (components, connections) = nodes_and_edges(); diff --git a/src/graph/retrieval.rs b/src/graph/retrieval.rs index 11d8a5d..d7d8d3a 100644 --- a/src/graph/retrieval.rs +++ b/src/graph/retrieval.rs @@ -3,8 +3,9 @@ //! Methods for retrieving components and connections from a [`ComponentGraph`]. -use crate::iterators::{Components, Connections, Neighbors}; +use crate::iterators::{Components, Connections, Neighbors, Siblings}; use crate::{ComponentGraph, Edge, Error, Node}; +use std::collections::BTreeSet; /// `Component` and `Connection` retrieval. impl ComponentGraph @@ -72,6 +73,73 @@ where Error::component_not_found(format!("Component with id {} not found.", component_id)) }) } + + /// Returns an iterator over the *siblings* of the component with the + /// given `component_id`, that have shared predecessors. + /// + /// Returns an error if the given `component_id` does not exist. + pub(crate) fn siblings_from_predecessors( + &self, + component_id: u64, + ) -> Result, Error> { + Ok(Siblings::new( + component_id, + self.predecessors(component_id)? + .map(|x| self.successors(x.component_id())) + .collect::, _>>()? + .into_iter() + .flatten(), + )) + } + + /// Returns an iterator over the *siblings* of the component with the + /// given `component_id`, that have shared successors. + /// + /// Returns an error if the given `component_id` does not exist. + pub(crate) fn siblings_from_successors(&self, component_id: u64) -> Result, Error> { + Ok(Siblings::new( + component_id, + self.successors(component_id)? + .map(|x| self.predecessors(x.component_id())) + .collect::, _>>()? + .into_iter() + .flatten(), + )) + } + + /// Returns a set of all components that match the given predicate, starting + /// from the component with the given `component_id`, in the given direction. + /// + /// If `follow_after_match` is `true`, the search continues deeper beyond + /// the matching components. + pub(crate) fn find_all( + &self, + from: u64, + mut pred: impl FnMut(&N) -> bool, + direction: petgraph::Direction, + follow_after_match: bool, + ) -> Result, Error> { + let index = self.node_indices.get(&from).ok_or_else(|| { + Error::component_not_found(format!("Component with id {} not found.", from)) + })?; + let mut stack = vec![*index]; + let mut found = BTreeSet::new(); + + while let Some(index) = stack.pop() { + let node = &self.graph[index]; + if pred(node) { + found.insert(node.component_id()); + if !follow_after_match { + continue; + } + } + + let neighbors = self.graph.neighbors_directed(index, direction); + stack.extend(neighbors); + } + + Ok(found) + } } #[cfg(test)] @@ -80,7 +148,8 @@ mod tests { use crate::component_category::BatteryType; use crate::component_category::CategoryPredicates; use crate::error::Error; - use crate::graph::test_types::{TestComponent, TestConnection}; + use crate::graph::test_utils::ComponentGraphBuilder; + use crate::graph::test_utils::{TestComponent, TestConnection}; use crate::ComponentCategory; use crate::InverterType; @@ -194,4 +263,146 @@ mod tests { Ok(()) } + + #[test] + fn test_siblings() -> Result<(), Error> { + let mut builder = ComponentGraphBuilder::new(); + let grid = builder.grid(); + + // Add a grid meter to the grid, with no successors. + let grid_meter = builder.meter(); + builder.connect(grid, grid_meter); + + assert_eq!(grid_meter.component_id(), 1); + + // Add a battery chain with three inverters and two battery. + let meter_bat_chain = builder.meter_bat_chain(3, 2); + builder.connect(grid_meter, meter_bat_chain); + + assert_eq!(meter_bat_chain.component_id(), 2); + + let graph = builder.build()?; + assert_eq!( + graph + .siblings_from_predecessors(3) + .unwrap() + .collect::>(), + [ + &TestComponent::new(5, ComponentCategory::Inverter(InverterType::Battery)), + &TestComponent::new(4, ComponentCategory::Inverter(InverterType::Battery)) + ] + ); + + assert_eq!( + graph + .siblings_from_successors(3) + .unwrap() + .collect::>(), + [ + &TestComponent::new(5, ComponentCategory::Inverter(InverterType::Battery)), + &TestComponent::new(4, ComponentCategory::Inverter(InverterType::Battery)) + ] + ); + + assert_eq!( + graph + .siblings_from_successors(6) + .unwrap() + .collect::>(), + Vec::<&TestComponent>::new() + ); + + assert_eq!( + graph + .siblings_from_predecessors(6) + .unwrap() + .collect::>(), + [&TestComponent::new( + 7, + ComponentCategory::Battery(BatteryType::LiIon) + )] + ); + + // Add two dangling meter to the grid meter + let dangling_meter = builder.meter(); + builder.connect(grid_meter, dangling_meter); + assert_eq!(dangling_meter.component_id(), 8); + + let dangling_meter = builder.meter(); + builder.connect(grid_meter, dangling_meter); + assert_eq!(dangling_meter.component_id(), 9); + + let graph = builder.build()?; + assert_eq!( + graph + .siblings_from_predecessors(8) + .unwrap() + .collect::>(), + [ + &TestComponent::new(9, ComponentCategory::Meter), + &TestComponent::new(2, ComponentCategory::Meter), + ] + ); + + Ok(()) + } + + #[test] + fn test_find_all() -> Result<(), Error> { + let (components, connections) = nodes_and_edges(); + let graph = ComponentGraph::try_new(components.clone(), connections.clone())?; + + let found = graph.find_all( + graph.root_id, + |x| x.is_meter(), + petgraph::Direction::Outgoing, + false, + )?; + assert_eq!(found, [2].iter().cloned().collect()); + + let found = graph.find_all( + graph.root_id, + |x| x.is_meter(), + petgraph::Direction::Outgoing, + true, + )?; + assert_eq!(found, [2, 3, 6].iter().cloned().collect()); + + let found = graph.find_all( + graph.root_id, + |x| !x.is_grid() && !graph.is_component_meter(x.component_id()).unwrap_or(false), + petgraph::Direction::Outgoing, + true, + )?; + assert_eq!(found, [2, 4, 5, 7, 8].iter().cloned().collect()); + + let found = graph.find_all( + 6, + |x| !x.is_grid() && !graph.is_component_meter(x.component_id()).unwrap_or(false), + petgraph::Direction::Outgoing, + true, + )?; + assert_eq!(found, [7, 8].iter().cloned().collect()); + + let found = graph.find_all( + graph.root_id, + |x| !x.is_grid() && !graph.is_component_meter(x.component_id()).unwrap_or(false), + petgraph::Direction::Outgoing, + false, + )?; + assert_eq!(found, [2].iter().cloned().collect()); + + let found = graph.find_all( + graph.root_id, + |_| true, + petgraph::Direction::Outgoing, + false, + )?; + assert_eq!(found, [1].iter().cloned().collect()); + + let found = graph.find_all(3, |_| true, petgraph::Direction::Outgoing, true)?; + assert_eq!(found, [3, 4, 5].iter().cloned().collect()); + + Ok(()) + } } diff --git a/src/graph/test_utils.rs b/src/graph/test_utils.rs new file mode 100644 index 0000000..24f0d77 --- /dev/null +++ b/src/graph/test_utils.rs @@ -0,0 +1,210 @@ +// License: MIT +// Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +//! This module is only compiled when running unit tests and contains features +//! that are shared by all tests of the `graph` modue. +//! +//! - the `TestComponent` and `TestConnection` types, which implement the `Node` +//! and `Edge` traits respectively. +//! - the `TestGraphBuilder`, which can declaratively build complex component +//! graph configurations for use in tests. + +use crate::{ + BatteryType, ComponentCategory, ComponentGraph, Edge, Error, EvChargerType, InverterType, Node, +}; + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct TestComponent(u64, ComponentCategory); + +impl TestComponent { + pub(super) fn new(id: u64, category: ComponentCategory) -> Self { + TestComponent(id, category) + } +} + +impl Node for TestComponent { + fn component_id(&self) -> u64 { + self.0 + } + + fn category(&self) -> ComponentCategory { + self.1.clone() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct TestConnection(u64, u64); + +impl TestConnection { + pub(super) fn new(source: u64, destination: u64) -> Self { + TestConnection(source, destination) + } +} + +impl Edge for TestConnection { + fn source(&self) -> u64 { + self.0 + } + + fn destination(&self) -> u64 { + self.1 + } +} + +/// Represents a component added to the `ComponentGraphBuilder`. +#[derive(Eq, Hash, PartialEq, Copy, Clone)] +pub(super) struct ComponentHandle(u64); + +impl ComponentHandle { + /// Returns the component ID of the component. + pub(super) fn component_id(&self) -> u64 { + self.0 + } +} + +/// A builder for creating complex component graph configurations easily, for +/// use in tests. +pub(super) struct ComponentGraphBuilder { + components: Vec, + connections: Vec, + next_id: u64, +} + +impl ComponentGraphBuilder { + /// Creates a new `ComponentGraphBuilder`. + pub(super) fn new() -> Self { + let builder = ComponentGraphBuilder { + components: Vec::new(), + connections: Vec::new(), + next_id: 0, + }; + builder + } + + /// Adds a component to the graph and returns its handle. + pub(super) fn add_component(&mut self, category: ComponentCategory) -> ComponentHandle { + let id = self.next_id; + self.next_id += 1; + self.components + .push(TestComponent::new(id, category.clone())); + let handle = ComponentHandle(id); + handle + } + + /// Adds a grid component to the graph and returns its handle. + pub(super) fn grid(&mut self) -> ComponentHandle { + self.add_component(ComponentCategory::Grid) + } + + /// Adds a meter to the graph and returns its handle. + pub(super) fn meter(&mut self) -> ComponentHandle { + self.add_component(ComponentCategory::Meter) + } + + /// Adds a battery to the graph and returns its handle. + pub(super) fn battery(&mut self) -> ComponentHandle { + self.add_component(ComponentCategory::Battery(BatteryType::LiIon)) + } + + /// Adds a battery inverter to the graph and returns its handle. + pub(super) fn battery_inverter(&mut self) -> ComponentHandle { + self.add_component(ComponentCategory::Inverter(InverterType::Battery)) + } + + /// Adds a solar inverter to the graph and returns its handle. + pub(super) fn solar_inverter(&mut self) -> ComponentHandle { + self.add_component(ComponentCategory::Inverter(InverterType::Solar)) + } + + /// Adds an EV charger to the graph and returns its handle. + pub(super) fn ev_charger(&mut self) -> ComponentHandle { + self.add_component(ComponentCategory::EvCharger(EvChargerType::Ac)) + } + + /// Adds a CHP to the graph and returns its handle. + pub(super) fn chp(&mut self) -> ComponentHandle { + self.add_component(ComponentCategory::Chp) + } + + /// Connects two components in the graph. + pub(super) fn connect(&mut self, from: ComponentHandle, to: ComponentHandle) -> &mut Self { + self.connections + .push(TestConnection::new(from.component_id(), to.component_id())); + self + } + + /// Adds a meter, followed by the given number of inverters and batteries, + /// and returns a handle to the meter. + pub(super) fn meter_bat_chain( + &mut self, + num_inverters: usize, + num_batteries: usize, + ) -> ComponentHandle { + let meter = self.meter(); + let mut inverters = vec![]; + for _ in 0..num_inverters { + let inverter = self.battery_inverter(); + self.connect(meter, inverter); + inverters.push(inverter); + } + for _ in 0..num_batteries { + let battery = self.battery(); + for inverter in &inverters { + self.connect(*inverter, battery); + } + } + meter + } + + /// Adds a battery inverter, followed by the given number of batteries, + /// and returns a handle to the battery inverter. + pub(super) fn inv_bat_chain(&mut self, num_batteries: usize) -> ComponentHandle { + let inverter = self.battery_inverter(); + let mut batteries = vec![]; + for _ in 0..num_batteries { + let battery = self.battery(); + self.connect(inverter, battery); + batteries.push(battery); + } + inverter + } + + /// Adds a meter, followed by the given number of PV inverters, and returns a + /// handle to the meter. + pub(super) fn meter_pv_chain(&mut self, num_inverters: usize) -> ComponentHandle { + let meter = self.meter(); + for _ in 0..num_inverters { + let inverter = self.solar_inverter(); + self.connect(meter, inverter); + } + meter + } + + /// Adds a meter, followed by the given number of CHPs, and returns a + /// handle to the meter. + pub(super) fn meter_chp_chain(&mut self, num_chp: usize) -> ComponentHandle { + let meter = self.meter(); + for _ in 0..num_chp { + let chp = self.chp(); + self.connect(meter, chp); + } + meter + } + + /// Adds a meter, followed by the given number of EV chargers, and returns a + /// handle to the meter. + pub(super) fn meter_ev_charger_chain(&mut self, num_ev_chargers: usize) -> ComponentHandle { + let meter = self.meter(); + for _ in 0..num_ev_chargers { + let ev_charger = self.ev_charger(); + self.connect(meter, ev_charger); + } + meter + } + + /// Builds and returns the component graph from the components and + /// connections added to the builder. + pub(super) fn build(&self) -> Result, Error> { + ComponentGraph::try_new(self.components.clone(), self.connections.clone()) + } +} diff --git a/src/graph/validation/validate_graph.rs b/src/graph/validation/validate_graph.rs index 3fd84ee..eeba470 100644 --- a/src/graph/validation/validate_graph.rs +++ b/src/graph/validation/validate_graph.rs @@ -83,7 +83,7 @@ where mod tests { use super::*; use crate::component_category::BatteryType; - use crate::graph::test_types::{TestComponent, TestConnection}; + use crate::graph::test_utils::{TestComponent, TestConnection}; use crate::ComponentCategory; use crate::ComponentGraph; use crate::InverterType; diff --git a/src/graph/validation/validate_neighbors.rs b/src/graph/validation/validate_neighbors.rs index 2710512..7d5af90 100644 --- a/src/graph/validation/validate_neighbors.rs +++ b/src/graph/validation/validate_neighbors.rs @@ -141,7 +141,7 @@ mod tests { use super::*; use crate::component_category::BatteryType; use crate::component_category::EvChargerType; - use crate::graph::test_types::{TestComponent, TestConnection}; + use crate::graph::test_utils::{TestComponent, TestConnection}; use crate::ComponentCategory; use crate::ComponentGraph; use crate::InverterType; diff --git a/src/graph_traits.rs b/src/graph_traits.rs index 1e92c7a..12baa7e 100644 --- a/src/graph_traits.rs +++ b/src/graph_traits.rs @@ -116,10 +116,6 @@ impl frequenz_microgrid_component_graph::Node for common::v1::microgrid::compone pb::ComponentCategory::Hvac => gr::ComponentCategory::Hvac, } } - - fn is_supported(&self) -> bool { - self.status != common::v1::microgrid::components::ComponentStatus::Inactive as i32 - } } ``` @@ -130,8 +126,6 @@ pub trait Node { fn component_id(&self) -> u64; /// Returns the category of the category. fn category(&self) -> ComponentCategory; - /// Returns true if the component can be read from and/or controlled. - fn is_supported(&self) -> bool; } /** diff --git a/src/lib.rs b/src/lib.rs index d92953a..1140d4d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,19 @@ including checking that: If any of the validation steps fail, the method will return an [`Error`], and a [`ComponentGraph`] instance otherwise. + +## Formula generation + +The component graph library has methods for generating formulas for various +metrics of the microgrid. The following formulas are supported: + +- [`grid_formula`][ComponentGraph::grid_formula] +- [`producer_formula`][ComponentGraph::producer_formula] +- [`consumer_formula`][ComponentGraph::consumer_formula] +- [`pv_formula`][ComponentGraph::pv_formula] +- [`battery_formula`][ComponentGraph::battery_formula] +- [`ev_charger_formula`][ComponentGraph::ev_charger_formula] +- [`chp_formula`][ComponentGraph::chp_formula] */ mod component_category;