diff --git a/.gitignore b/.gitignore index 6997f69..f2fc28f 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,4 @@ cython_debug/ # Automatically generated documentation docs/reference/ site/ +.cargo/ diff --git a/python/frequenz/microgrid_component_graph/__init__.pyi b/python/frequenz/microgrid_component_graph/__init__.pyi index 1e2a668..be7bed0 100644 --- a/python/frequenz/microgrid_component_graph/__init__.pyi +++ b/python/frequenz/microgrid_component_graph/__init__.pyi @@ -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. diff --git a/src/category.rs b/src/category.rs index cef7de1..24ae134 100644 --- a/src/category.rs +++ b/src/category.rs @@ -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> { @@ -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), @@ -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: {:?}", diff --git a/src/graph.rs b/src/graph.rs index c75f2ba..48ba28c 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -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) -> PyResult> +where + I: Iterator, + E: std::fmt::Display, +{ + let iter = + result.map_err(|e| PyErr::new::(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 { @@ -178,27 +193,40 @@ impl ComponentGraph { fn predecessors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { Python::attach(|py| { - PySet::new( + neighbors_set( py, self.graph - .predecessors(extract_int::(py, component_id)?) - .map_err(|e| PyErr::new::(e.to_string()))? - .map(|c| c.object.bind(py)), + .predecessors(extract_int::(py, component_id)?), ) - .map(|s| s.into()) }) } fn successors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { Python::attach(|py| { - PySet::new( + neighbors_set( + py, + self.graph.successors(extract_int::(py, component_id)?), + ) + }) + } + + fn raw_predecessors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { + Python::attach(|py| { + neighbors_set( + py, + self.graph + .raw_predecessors(extract_int::(py, component_id)?), + ) + }) + } + + fn raw_successors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { + Python::attach(|py| { + neighbors_set( py, self.graph - .successors(extract_int::(py, component_id)?) - .map_err(|e| PyErr::new::(e.to_string()))? - .map(|c| c.object.bind(py)), + .raw_successors(extract_int::(py, component_id)?), ) - .map(|s| s.into()) }) } diff --git a/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index 6cf4edb..c6d4682 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -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, ) @@ -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 → → 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}