From 9ca9151b47e564dd53a8fbf7f108d5b2f427fa24 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Tue, 28 Apr 2026 11:04:42 +0000 Subject: [PATCH 1/5] Map upstream pass-through component classes to their Rust categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `category_from_python_component` now recognises six additional classes from `frequenz-client-microgrid` that correspond to the Rust crate's pass-through categories: Converter, Precharger, Electrolyzer, Hvac, CryptoMiner (1:1 names) VoltageTransformer → cg::ComponentCategory::PowerTransformer The last one is a name mismatch the upstream client and the Rust enum carry from before this work — there's no `PowerTransformer` class on the Python side and no `VoltageTransformer` variant on the Rust side, so the mapping crosses the gap. Adds a `test_passthrough_voltage_transformer` end-to-end test: builds `Grid → VoltageTransformer → Meter → SolarInverter`, verifies the graph constructs (the validator walks past the transformer when checking the meter's predecessor), and asserts that `predecessors` / `successors` skip it. The next commit extends the test to also cover `raw_predecessors` / `raw_successors` once those are exposed. Signed-off-by: Sahas Subramanian --- src/category.rs | 33 +++++++++ tests/test_microgrid_component_graph.py | 98 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/src/category.rs b/src/category.rs index cef7de1..619ccfd 100644 --- a/src/category.rs +++ b/src/category.rs @@ -15,6 +15,12 @@ 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>, } impl<'py> ComponentClasses<'py> { @@ -39,6 +45,12 @@ 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")?, }); } Err(e) => last_err = Some(e), @@ -92,6 +104,27 @@ 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 { Err(exceptions::PyValueError::new_err(format!( "Unsupported component category: {:?}", diff --git a/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index 6cf4edb..77c14f0 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -3,14 +3,23 @@ """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, SolarInverter, + VoltageTransformer, WindTurbine, ) @@ -124,3 +133,92 @@ 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} + + +@pytest.mark.parametrize( + "passthrough_class, passthrough_kwargs", + [ + (Converter, {}), + (Precharger, {}), + (Electrolyzer, {}), + (Hvac, {}), + (CryptoMiner, {}), + ], +) +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} From e5b07b359ee206070046bd9e2692fbcd519c7d43 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Tue, 28 Apr 2026 11:11:05 +0000 Subject: [PATCH 2/5] Expose raw_predecessors / raw_successors on Python ComponentGraph Wraps the Rust crate's `raw_predecessors` and `raw_successors` methods on the pyclass so Python callers can ask for the unfiltered, graph-direct view of a node's neighbors (the one that includes pass-through nodes), as a counterpart to the now-pass-through-aware `predecessors` and `successors`. Updates the type stubs to declare both pairs of methods and document which is which. Extends the `test_passthrough_voltage_transformer` test from the previous commits with assertions on the new `raw_*` methods, exposing the pass-through that the effective view skips. Signed-off-by: Sahas Subramanian --- .../microgrid_component_graph/__init__.pyi | 60 +++++++++++++++++-- src/graph.rs | 26 ++++++++ tests/test_microgrid_component_graph.py | 4 ++ 3 files changed, 86 insertions(+), 4 deletions(-) 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/graph.rs b/src/graph.rs index c75f2ba..7fe3440 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -202,6 +202,32 @@ impl ComponentGraph { }) } + fn raw_predecessors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { + Python::attach(|py| { + PySet::new( + py, + self.graph + .raw_predecessors(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string()))? + .map(|c| c.object.bind(py)), + ) + .map(|s| s.into()) + }) + } + + fn raw_successors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { + Python::attach(|py| { + PySet::new( + py, + self.graph + .raw_successors(extract_int::(py, component_id)?) + .map_err(|e| PyErr::new::(e.to_string()))? + .map(|c| c.object.bind(py)), + ) + .map(|s| s.into()) + }) + } + fn is_pv_meter(&self, py: Python<'_>, component_id: Bound<'_, PyAny>) -> PyResult { self.graph .is_pv_meter(extract_int::(py, component_id)?) diff --git a/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index 77c14f0..e7be032 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -180,6 +180,10 @@ def test_passthrough_voltage_transformer() -> None: # 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", From a7bde7e78097b53ea4de960949f13dc674ea6537 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 30 Apr 2026 09:41:02 +0000 Subject: [PATCH 3/5] Extract neighbors_set helper The four pyclass methods that wrap the Rust crate's `predecessors` / `successors` / `raw_predecessors` / `raw_successors` had near-identical bodies: extract the component id, dispatch to the rust method, map the error onto `ValueError`, and build a `PySet` from the yielded `&Component`s. Pull that boilerplate into a private `neighbors_set` free function so each method shrinks to a single delegating call. Pure refactor; no behavior or API change. Signed-off-by: Sahas Subramanian --- src/graph.rs | 44 +++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/graph.rs b/src/graph.rs index 7fe3440..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,53 +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)?) - .map_err(|e| PyErr::new::(e.to_string()))? - .map(|c| c.object.bind(py)), + self.graph.successors(extract_int::(py, component_id)?), ) - .map(|s| s.into()) }) } fn raw_predecessors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { Python::attach(|py| { - PySet::new( + neighbors_set( py, self.graph - .raw_predecessors(extract_int::(py, component_id)?) - .map_err(|e| PyErr::new::(e.to_string()))? - .map(|c| c.object.bind(py)), + .raw_predecessors(extract_int::(py, component_id)?), ) - .map(|s| s.into()) }) } fn raw_successors(&self, component_id: Bound<'_, PyAny>) -> PyResult> { Python::attach(|py| { - PySet::new( + neighbors_set( py, self.graph - .raw_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()) }) } From 85c28a75a24805cbab268a66b47c2e1f589d73c5 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Tue, 28 Apr 2026 12:49:01 +0000 Subject: [PATCH 4/5] Map upstream Relay class to cg::ComponentCategory::Breaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upstream `frequenz-client-microgrid` client publishes a `Relay` class, but internally it carries the protobuf `ELECTRICAL_COMPONENT_CATEGORY_BREAKER` value (see `_category.py`'s `RELAY = ELECTRICAL_COMPONENT_CATEGORY_BREAKER`). The Rust crate doesn't have a separate `Relay` variant either — `Breaker` covers both, so the mapping lands there to match the upstream protobuf source of truth. Like the other pass-through mappings added two commits ago, `Relay` becomes transparent to validators and formula generators. Extends the `test_passthrough_category_recognized` parametrize list with `Relay` so the new arm is exercised. The Python-side cleanup to expose `Breaker` directly instead of carrying the `Relay` alias is left for a follow-up. Signed-off-by: Sahas Subramanian --- src/category.rs | 10 ++++++++++ tests/test_microgrid_component_graph.py | 2 ++ 2 files changed, 12 insertions(+) diff --git a/src/category.rs b/src/category.rs index 619ccfd..24ae134 100644 --- a/src/category.rs +++ b/src/category.rs @@ -21,6 +21,7 @@ struct ComponentClasses<'py> { voltage_transformer: Bound<'py, PyAny>, hvac: Bound<'py, PyAny>, crypto_miner: Bound<'py, PyAny>, + relay: Bound<'py, PyAny>, } impl<'py> ComponentClasses<'py> { @@ -51,6 +52,7 @@ impl<'py> ComponentClasses<'py> { voltage_transformer: module.getattr("VoltageTransformer")?, hvac: module.getattr("Hvac")?, crypto_miner: module.getattr("CryptoMiner")?, + relay: module.getattr("Relay")?, }); } Err(e) => last_err = Some(e), @@ -125,6 +127,14 @@ pub(crate) fn category_from_python_component( || 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/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index e7be032..c6d4682 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -18,6 +18,7 @@ Hvac, Meter, Precharger, + Relay, SolarInverter, VoltageTransformer, WindTurbine, @@ -193,6 +194,7 @@ def test_passthrough_voltage_transformer() -> None: (Electrolyzer, {}), (Hvac, {}), (CryptoMiner, {}), + (Relay, {}), ], ) def test_passthrough_category_recognized( From 5e2d4cbc2ffc1c059bf3bb175cc8f1885d90dffb Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Tue, 28 Apr 2026 11:11:16 +0000 Subject: [PATCH 5/5] Ignore local .cargo/ directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local Cargo overrides (e.g. `[paths]` in `.cargo/config.toml`) shouldn't be checked in — they're per-developer, used to point this crate at sibling local repos during cross-repo development. Adds the directory to .gitignore so the clutter stays out of `git status`. Signed-off-by: Sahas Subramanian --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/