Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
- `component_formula()` and `component_ac_coalesce_formula()` now check the given component id and return an error when it is not in the graph. Before, they returned a formula for any id. Handle the error, or pass only ids that are in the graph.

## New Features

<!-- Here goes the main new features and examples or instructions on how to use them -->
- The `Node` trait has a new `operational_mode()` method. The default is `OperationalMode::Unspecified`, which is treated as providing telemetry, so existing `Node` implementations keep their behavior. A component whose mode provides no telemetry is not used as a measurement source in formulas. It is still used to classify the meter that measures it (e.g. as a PV meter or a CHP meter). A coalesce formula can be `None` when no source component provides telemetry.

- For a component that provides no telemetry, `component_formula()` returns a `0.0` formula and `component_ac_coalesce_formula()` returns `None`.

## Bug Fixes

Expand Down
10 changes: 10 additions & 0 deletions src/component_category.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,16 @@ pub(crate) trait CategoryPredicates: Node {
fn is_steam_boiler(&self) -> bool {
self.category() == ComponentCategory::SteamBoiler
}

/// Returns `true` if the component provides telemetry data, and so can be a
/// measurement source in a formula.
///
/// Determined by the component's [operational mode][Node::operational_mode].
/// A component that does not provide telemetry is still used to classify the
/// meter that measures it (e.g. as a PV meter or a CHP meter).
fn provides_telemetry(&self) -> bool {
self.operational_mode().provides_telemetry()
}
}

/// Implement the `CategoryPredicates` trait for all types that implement the
Expand Down
80 changes: 79 additions & 1 deletion src/graph/formulas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,15 @@ where
}

/// Returns the formula for a specific component by its ID.
///
/// A component that provides no telemetry has no reading to emit, so its
/// formula is `0.0`.
///
/// Returns an error when `component_id` is not in the graph.
pub fn component_formula(&self, component_id: u64) -> Result<Formula, Error> {
if !self.component(component_id)?.provides_telemetry() {
return Ok(Expr::number(0.0).into());
}
Ok(Expr::component(component_id).into())
}

Expand All @@ -112,6 +120,9 @@ where
/// The formula is a `COALESCE` expression that includes all meters,
/// PV inverters, and battery inverters that are directly connected to the
/// grid.
///
/// A component that provides no telemetry is skipped. When no component
/// provides telemetry, the formula is `None`.
pub fn grid_coalesce_formula(&self) -> Result<Formula, Error> {
generators::grid_coalesce::GridCoalesceFormulaBuilder::try_new(self)?.build()
}
Expand All @@ -126,6 +137,9 @@ where
///
/// When the `battery_ids` parameter is `None`, it will include all the
/// battery meters and inverters in the graph.
///
/// A component that provides no telemetry is skipped. When no component
/// provides telemetry, the formula is `None`.
pub fn battery_ac_coalesce_formula(
&self,
battery_ids: Option<BTreeSet<u64>>,
Expand All @@ -147,6 +161,9 @@ where
///
/// When the `pv_inverter_ids` parameter is `None`, it will include all the
/// PV meters and inverters in the graph.
///
/// A component that provides no telemetry is skipped. When no component
/// provides telemetry, the formula is `None`.
pub fn pv_ac_coalesce_formula(
&self,
pv_inverter_ids: Option<BTreeSet<u64>>,
Expand All @@ -156,7 +173,15 @@ where
}

/// Returns the AC coalesce formula for a specific component by its ID.
///
/// A component that provides no telemetry has no reading to emit, so its
/// formula is `None`.
///
/// Returns an error when `component_id` is not in the graph.
pub fn component_ac_coalesce_formula(&self, component_id: u64) -> Result<Formula, Error> {
if !self.component(component_id)?.provides_telemetry() {
return Ok(Expr::None.into());
}
Ok(Expr::component(component_id).into())
}

Expand All @@ -177,7 +202,10 @@ where

#[cfg(test)]
mod tests {
use crate::{Error, graph::test_utils::ComponentGraphBuilder};
use crate::{
ComponentCategory, Error, InverterType, OperationalMode,
graph::test_utils::ComponentGraphBuilder,
};

/// `component_formula` and `component_ac_coalesce_formula` return the bare
/// reading of the requested component — no meter fallback, even when the
Expand All @@ -203,4 +231,54 @@ mod tests {
);
Ok(())
}

/// A component that provides no telemetry has no reading, so its formula is
/// `0.0` (`None` for the AC coalesce variant).
#[test]
fn test_component_formula_no_telemetry() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let meter = builder.meter();
let inverter = builder.add_component_with_mode(
ComponentCategory::Inverter(InverterType::Battery),
OperationalMode::ControlOnly,
);
let battery = builder.battery();
builder.connect(grid, meter);
builder.connect(meter, inverter);
builder.connect(inverter, battery);

let graph = builder.build(None)?;
let inv = inverter.component_id();

assert_eq!(graph.component_formula(inv)?.to_string(), "0.0");
assert_eq!(
graph.component_ac_coalesce_formula(inv)?.to_string(),
"None"
);
Ok(())
}

/// Both component formula variants check the given id and return an error
/// when it is not in the graph.
#[test]
fn test_component_formula_unknown_id() -> Result<(), Error> {
let mut builder = ComponentGraphBuilder::new();
let grid = builder.grid();
let meter = builder.meter();
builder.connect(grid, meter);
let graph = builder.build(None)?;

assert!(
graph
.component_formula(99)
.is_err_and(|e| e == Error::component_not_found("Component with id 99 not found."))
);
assert!(
graph
.component_ac_coalesce_formula(99)
.is_err_and(|e| e == Error::component_not_found("Component with id 99 not found."))
);
Ok(())
}
}
18 changes: 15 additions & 3 deletions src/graph/formulas/fallback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ use crate::{ComponentGraph, Edge, Error, Node};

use super::expr::Expr;
use emit::{diamond_term, measure, subtraction_term, sum};
pub(super) use predicates::ids_with_telemetry;
pub(crate) use predicates::is_grid_meter;
use resolve::{Measurement, measurement_points};

Expand Down Expand Up @@ -119,20 +120,31 @@ pub(crate) fn aggregate_terms<N: Node, E: Edge>(
policy: SourcePreference,
) -> Result<Vec<Expr>, Error> {
if graph.config.disable_fallback_components {
Ok(targets.into_iter().map(Expr::component).collect())
// Without fallback, each target is measured by its own reading; a target
// that provides no telemetry has no reading to emit, so it is dropped.
let mut terms: Vec<Expr> = ids_with_telemetry(graph, targets.iter().copied())?
.into_iter()
.map(Expr::component)
.collect();
// If every target was dropped for lack of telemetry, keep the term total
// with a 0.0. A genuinely empty target set stays empty, as before.
if terms.is_empty() && !targets.is_empty() {
terms.push(Expr::number(0.0));
}
Ok(terms)
} 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)
diamond_term(graph, &components, &meters, policy)
}
Measurement::Subtraction {
parent_meters,
subtracted,
components,
} => subtraction_term(&parent_meters, &subtracted, &components, policy),
} => subtraction_term(graph, &parent_meters, &subtracted, &components, policy),
})
.collect()
}
Expand Down
Loading
Loading