Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,4 @@ cython_debug/
# Automatically generated documentation
docs/reference/
site/
.cargo/
60 changes: 56 additions & 4 deletions python/frequenz/microgrid_component_graph/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -148,26 +148,78 @@ class ComponentGraph(Generic[ComponentT, ConnectionT, ComponentIdT]):
"""

def predecessors(self, component_id: ComponentIdT) -> Set[ComponentT]:
"""Fetch all predecessors of the specified component ID.
"""Fetch the *effective* (pass-through-aware) predecessors of the
specified component ID.

Pass-through component categories are walked transparently:
their non-pass-through ancestors take their place in the result.
For the raw graph view that includes pass-throughs, use
`raw_predecessors`.

Args:
component_id: ID of the component whose predecessors should be fetched.

Returns:
A set of components that are predecessors of the given component ID.
A set of components that are effective predecessors of the
given component ID.

Raises:
ValueError: if no component exists with the given ID.
"""

def successors(self, component_id: ComponentIdT) -> Set[ComponentT]:
"""Fetch all successors of the specified component ID.
"""Fetch the *effective* (pass-through-aware) successors of the
specified component ID.

Pass-through component categories are walked transparently:
their non-pass-through descendants take their place in the
result. For the raw graph view that includes pass-throughs, use
`raw_successors`.

Args:
component_id: ID of the component whose successors should be fetched.

Returns:
A set of components that are successors of the given component ID.
A set of components that are effective successors of the
given component ID.

Raises:
ValueError: if no component exists with the given ID.
"""

def raw_predecessors(self, component_id: ComponentIdT) -> Set[ComponentT]:
"""Fetch the *raw* (graph-direct) predecessors of the specified
component ID, including pass-through nodes.

Most callers want `predecessors` instead, which walks past
pass-throughs transparently.

Args:
component_id: ID of the component whose raw predecessors
should be fetched.

Returns:
A set of every component connected to the given ID by an
incoming edge.

Raises:
ValueError: if no component exists with the given ID.
"""

def raw_successors(self, component_id: ComponentIdT) -> Set[ComponentT]:
"""Fetch the *raw* (graph-direct) successors of the specified
component ID, including pass-through nodes.

Most callers want `successors` instead, which walks past
pass-throughs transparently.

Args:
component_id: ID of the component whose raw successors
should be fetched.

Returns:
A set of every component connected to the given ID by an
outgoing edge.

Raises:
ValueError: if no component exists with the given ID.
Expand Down
43 changes: 43 additions & 0 deletions src/category.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ struct ComponentClasses<'py> {
solar_inverter: Bound<'py, PyAny>,
hybrid_inverter: Bound<'py, PyAny>,
unspecified_component: Bound<'py, PyAny>,
converter: Bound<'py, PyAny>,
precharger: Bound<'py, PyAny>,
electrolyzer: Bound<'py, PyAny>,
voltage_transformer: Bound<'py, PyAny>,
hvac: Bound<'py, PyAny>,
crypto_miner: Bound<'py, PyAny>,
relay: Bound<'py, PyAny>,
}

impl<'py> ComponentClasses<'py> {
Expand All @@ -39,6 +46,13 @@ impl<'py> ComponentClasses<'py> {
solar_inverter: module.getattr("SolarInverter")?,
hybrid_inverter: module.getattr("HybridInverter")?,
unspecified_component: module.getattr("UnspecifiedComponent")?,
converter: module.getattr("Converter")?,
precharger: module.getattr("Precharger")?,
electrolyzer: module.getattr("Electrolyzer")?,
voltage_transformer: module.getattr("VoltageTransformer")?,
hvac: module.getattr("Hvac")?,
crypto_miner: module.getattr("CryptoMiner")?,
relay: module.getattr("Relay")?,
});
}
Err(e) => last_err = Some(e),
Expand Down Expand Up @@ -92,6 +106,35 @@ pub(crate) fn category_from_python_component(
|| object.is(&comp_classes.unspecified_component)
{
Ok(cg::ComponentCategory::Unspecified)
} else if object.is_instance(&comp_classes.converter)? || object.is(&comp_classes.converter) {
Ok(cg::ComponentCategory::Converter)
} else if object.is_instance(&comp_classes.precharger)? || object.is(&comp_classes.precharger) {
Ok(cg::ComponentCategory::Precharger)
} else if object.is_instance(&comp_classes.electrolyzer)?
|| object.is(&comp_classes.electrolyzer)
{
Ok(cg::ComponentCategory::Electrolyzer)
} else if object.is_instance(&comp_classes.voltage_transformer)?
|| object.is(&comp_classes.voltage_transformer)
{
// The upstream client publishes this as `VoltageTransformer`,
// while the Rust crate's enum names the same concept
// `PowerTransformer`. Map across the naming difference here.
Ok(cg::ComponentCategory::PowerTransformer)
} else if object.is_instance(&comp_classes.hvac)? || object.is(&comp_classes.hvac) {
Ok(cg::ComponentCategory::Hvac)
} else if object.is_instance(&comp_classes.crypto_miner)?
|| object.is(&comp_classes.crypto_miner)
{
Ok(cg::ComponentCategory::CryptoMiner)
} else if object.is_instance(&comp_classes.relay)? || object.is(&comp_classes.relay) {
// The upstream client's `Relay` is internally the protobuf
// `BREAKER` enum value (see `_category.py`'s
// `RELAY = ELECTRICAL_COMPONENT_CATEGORY_BREAKER`), so map
// it onto the Rust crate's `Breaker` variant. The Python side
// here will likely move to using the breaker name directly in
// a follow-up cleanup.
Ok(cg::ComponentCategory::Breaker)
} else {
Err(exceptions::PyValueError::new_err(format!(
"Unsupported component category: {:?}",
Expand Down
48 changes: 38 additions & 10 deletions src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ use pyo3::{
types::{PyAny, PySet, PyType},
};

/// Builds a Python `set` from the result of a graph-neighbor lookup.
///
/// Maps a `cg::Error` onto a `ValueError` and converts each yielded
/// `&Component` into the underlying Python object before building the
/// set.
fn neighbors_set<'a, I, E>(py: Python<'_>, result: Result<I, E>) -> PyResult<Py<PySet>>
where
I: Iterator<Item = &'a Component>,
E: std::fmt::Display,
{
let iter =
result.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?;
PySet::new(py, iter.map(|c| c.object.bind(py))).map(|s| s.into())
}

#[pyclass(subclass)]
#[derive(Clone, Default, Debug)]
pub struct ComponentGraphConfig {
Expand Down Expand Up @@ -178,27 +193,40 @@ impl ComponentGraph {

fn predecessors(&self, component_id: Bound<'_, PyAny>) -> PyResult<Py<PySet>> {
Python::attach(|py| {
PySet::new(
neighbors_set(
py,
self.graph
.predecessors(extract_int::<u64>(py, component_id)?)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?
.map(|c| c.object.bind(py)),
.predecessors(extract_int::<u64>(py, component_id)?),
)
.map(|s| s.into())
})
}

fn successors(&self, component_id: Bound<'_, PyAny>) -> PyResult<Py<PySet>> {
Python::attach(|py| {
PySet::new(
neighbors_set(
py,
self.graph.successors(extract_int::<u64>(py, component_id)?),
)
})
}

fn raw_predecessors(&self, component_id: Bound<'_, PyAny>) -> PyResult<Py<PySet>> {
Python::attach(|py| {
neighbors_set(
py,
self.graph
.raw_predecessors(extract_int::<u64>(py, component_id)?),
)
})
}

fn raw_successors(&self, component_id: Bound<'_, PyAny>) -> PyResult<Py<PySet>> {
Python::attach(|py| {
neighbors_set(
py,
self.graph
.successors(extract_int::<u64>(py, component_id)?)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyValueError, _>(e.to_string()))?
.map(|c| c.object.bind(py)),
.raw_successors(extract_int::<u64>(py, component_id)?),
)
.map(|s| s.into())
})
}

Expand Down
104 changes: 104 additions & 0 deletions tests/test_microgrid_component_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,24 @@

"""Tests for the frequenz.microgrid_component_graph package."""

from typing import Any

import pytest
from frequenz.client.common.microgrid import MicrogridId
from frequenz.client.common.microgrid.components import ComponentId
from frequenz.client.microgrid.component import (
Component,
ComponentConnection,
Converter,
CryptoMiner,
Electrolyzer,
GridConnectionPoint,
Hvac,
Meter,
Precharger,
Relay,
SolarInverter,
VoltageTransformer,
WindTurbine,
)

Expand Down Expand Up @@ -124,3 +134,97 @@ def test_wind_turbine_graph() -> None:
assert graph.predecessors(ComponentId(3)) == {
Meter(id=ComponentId(2), microgrid_id=MicrogridId(1))
}


def test_passthrough_voltage_transformer() -> None:
"""A pass-through component (VoltageTransformer) is transparent to
validators and formula generators.

Topology: ``Grid → VoltageTransformer → Meter → SolarInverter``.
The VoltageTransformer maps to ``cg::ComponentCategory::PowerTransformer``,
which is a pass-through. Building the graph succeeds (the validator
walks past the transformer when checking the meter's predecessor),
and topology queries return the *effective* (non-pass-through)
neighbors.
"""
grid = GridConnectionPoint(
id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=100
)
transformer = VoltageTransformer(
id=ComponentId(2),
microgrid_id=MicrogridId(1),
primary_voltage=20_000.0,
secondary_voltage=400.0,
)
meter = Meter(id=ComponentId(3), microgrid_id=MicrogridId(1))
inverter = SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1))

graph: microgrid_component_graph.ComponentGraph[
Component, ComponentConnection, ComponentId
] = microgrid_component_graph.ComponentGraph(
components={grid, transformer, meter, inverter},
connections={
ComponentConnection(source=ComponentId(1), destination=ComponentId(2)),
ComponentConnection(source=ComponentId(2), destination=ComponentId(3)),
ComponentConnection(source=ComponentId(3), destination=ComponentId(4)),
},
)

# All four components are in the graph.
assert graph.components() == {grid, transformer, meter, inverter}

# The meter's effective predecessor is the grid (the transformer is
# walked past).
assert graph.predecessors(ComponentId(3)) == {grid}

# The grid's effective successor is the meter (skipping the
# transformer).
assert graph.successors(ComponentId(1)) == {meter}

# raw_predecessors / raw_successors expose the transformer.
assert graph.raw_predecessors(ComponentId(3)) == {transformer}
assert graph.raw_successors(ComponentId(1)) == {transformer}


@pytest.mark.parametrize(
"passthrough_class, passthrough_kwargs",
[
(Converter, {}),
(Precharger, {}),
(Electrolyzer, {}),
(Hvac, {}),
(CryptoMiner, {}),
(Relay, {}),
],
)
def test_passthrough_category_recognized(
passthrough_class: type, passthrough_kwargs: dict[str, Any]
) -> None:
"""Each pass-through component class added in this commit is
recognized by the bindings, and the meter beneath it sees the grid
as its effective predecessor.

Topology: ``Grid → <PT> → Meter → SolarInverter``.
"""
grid = GridConnectionPoint(
id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=100
)
passthrough = passthrough_class(
id=ComponentId(2), microgrid_id=MicrogridId(1), **passthrough_kwargs
)
meter = Meter(id=ComponentId(3), microgrid_id=MicrogridId(1))
inverter = SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1))

graph: microgrid_component_graph.ComponentGraph[
Component, ComponentConnection, ComponentId
] = microgrid_component_graph.ComponentGraph(
components={grid, passthrough, meter, inverter},
connections={
ComponentConnection(source=ComponentId(1), destination=ComponentId(2)),
ComponentConnection(source=ComponentId(2), destination=ComponentId(3)),
ComponentConnection(source=ComponentId(3), destination=ComponentId(4)),
},
)

assert passthrough in graph.components()
assert graph.predecessors(ComponentId(3)) == {grid}
Loading