From 1594ad3a0e92c38c215ea9d7cf804105c6b78862 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 7 May 2026 14:50:14 +0000 Subject: [PATCH 1/5] Cover all microgrid client categories in category mapping category_from_python_component previously handled only a subset of cg::ComponentCategory: GridConnectionPoint, Meter, Battery (Unspecified), the three concrete inverter subtypes, EvCharger (Unspecified), Chp, WindTurbine, and Unspecified. Cover everything cg defines. Concrete subtype classes from the microgrid client are checked before their abstract parent so the cg-side subtype is preserved instead of collapsing to Unspecified: * battery: LiIonBattery, NaIonBattery * ev_charger: AcEvCharger, DcEvCharger, HybridEvCharger * inverter: Inverter parent catches Unspecified/UnrecognizedInverter Pass-through categories are also mapped: Converter, CryptoMiner, Electrolyzer, Hvac, Precharger, Relay (-> cg::Breaker), and VoltageTransformer (-> cg::PowerTransformer). UnrecognizedComponent joins UnspecifiedComponent in mapping to cg::Unspecified. SteamBoiler (new in frequenz-client-microgrid 0.18.3) is mapped to cg::SteamBoiler; the `microgrid` extra's lower bound is bumped to 0.18.3 to match. Field order mirrors the ComponentCategory enum in frequenz.client.microgrid.component._category. Forward-compat aliases (Breaker, PowerTransformer) and cg-only categories that no provider exposes today (Plc, StaticTransferSwitch, UninterruptiblePowerSupply, CapacitorBank) are probed optionally so an alternative provider that exposes them works without a code change. Signed-off-by: Sahas Subramanian --- pyproject.toml | 2 +- src/category.rs | 343 +++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 300 insertions(+), 45 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6e4bf6a..0f9bd0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ email = "floss@frequenz.com" [project.optional-dependencies] microgrid = [ - "frequenz-client-microgrid >= 0.18.0, < 0.19", + "frequenz-client-microgrid >= 0.18.3, < 0.19", ] assets = [ "frequenz-client-assets >= 0.1.0, < 0.3", diff --git a/src/category.rs b/src/category.rs index cef7de1..055d0e7 100644 --- a/src/category.rs +++ b/src/category.rs @@ -4,17 +4,113 @@ use frequenz_microgrid_component_graph as cg; use pyo3::{exceptions, prelude::*}; +/// Bindings to the Python classes that map to a [`cg::ComponentCategory`]. +/// +/// Field order mirrors the `ComponentCategory` enum in +/// `frequenz.client.microgrid.component._category` so that adding, +/// removing, or reshuffling a category here is an obvious visual diff +/// against the upstream client. Categories present only on the cg side +/// (no client class) follow at the end. +/// +/// Classes carried as `Bound` are required to exist in the +/// resolved provider module — these are the classes the microgrid +/// client ships today that aren't expected to be renamed or removed. +/// Classes carried as `Option>` are looked up with +/// `.ok()` so the bindings keep working when the class isn't +/// present: forward-compat aliases the client may adopt later +/// (`Breaker`, `PowerTransformer`), classes paired with such an +/// alias that may themselves disappear after a rename (`Relay`, +/// `VoltageTransformer`), or cg-only categories no current provider +/// exposes (`Plc`, `CapacitorBank`, …). struct ComponentClasses<'py> { + // UNSPECIFIED — every problematic-component subclass collapses + // to `cg::Unspecified`. + unspecified_component: Bound<'py, PyAny>, + unrecognized_component: Bound<'py, PyAny>, + + // GRID_CONNECTION_POINT grid_connection_point: Bound<'py, PyAny>, + + // METER meter: Bound<'py, PyAny>, + + // INVERTER (subtypes first; parent class catches Unspecified* + // and Unrecognized* subclasses). + battery_inverter: Bound<'py, PyAny>, + hybrid_inverter: Bound<'py, PyAny>, + solar_inverter: Bound<'py, PyAny>, + inverter: Bound<'py, PyAny>, + + // CONVERTER + converter: Bound<'py, PyAny>, + + // BATTERY (subtypes first; parent class catches Unspecified* + // and Unrecognized* subclasses). + li_ion_battery: Bound<'py, PyAny>, + na_ion_battery: Bound<'py, PyAny>, battery: Bound<'py, PyAny>, + + // EV_CHARGER (subtypes first; parent class catches Unspecified* + // and Unrecognized* subclasses). + ac_ev_charger: Bound<'py, PyAny>, + dc_ev_charger: Bound<'py, PyAny>, + hybrid_ev_charger: Bound<'py, PyAny>, ev_charger: Bound<'py, PyAny>, + + // CRYPTO_MINER + crypto_miner: Bound<'py, PyAny>, + + // ELECTROLYZER + electrolyzer: Bound<'py, PyAny>, + + // CHP chp: Bound<'py, PyAny>, + + // RELAY → cg::Breaker. The microgrid client today ships a + // class named `Relay`; the underlying protobuf tag has always + // been BREAKER, and a future client may rename the class to + // match. `Option<>` here so a rename that drops `Relay` + // doesn't break the bindings. + relay: Option>, + // Forward-compat alias: matches if the client renames its + // class to the protobuf-canonical `Breaker`. One of + // `relay`/`breaker` will become redundant after such a rename + // and can be dropped. + breaker: Option>, + + // PRECHARGER + precharger: Bound<'py, PyAny>, + + // POWER_TRANSFORMER → cg::PowerTransformer. The microgrid + // client today ships a class named `VoltageTransformer`; the + // protobuf enum was renamed from VOLTAGE_TRANSFORMER to + // POWER_TRANSFORMER (with VOLTAGE_TRANSFORMER kept only as a + // deprecated enum alias), but the class hasn't followed yet. + // `Option<>` here so a rename that drops `VoltageTransformer` + // doesn't break the bindings. + voltage_transformer: Option>, + // Forward-compat alias: matches if the client renames its + // class to the protobuf-canonical `PowerTransformer`. One of + // `voltage_transformer`/`power_transformer` will become + // redundant after such a rename and can be dropped. + power_transformer: Option>, + + // HVAC + hvac: Bound<'py, PyAny>, + + // WIND_TURBINE wind_turbine: Bound<'py, PyAny>, - battery_inverter: Bound<'py, PyAny>, - solar_inverter: Bound<'py, PyAny>, - hybrid_inverter: Bound<'py, PyAny>, - unspecified_component: Bound<'py, PyAny>, + + // STEAM_BOILER + steam_boiler: Bound<'py, PyAny>, + + // cg-only categories (no class in the microgrid client today; + // probed optionally so an alternative provider that exposes + // them works without a code change). + plc: Option>, + static_transfer_switch: Option>, + uninterruptible_power_supply: Option>, + capacitor_bank: Option>, } impl<'py> ComponentClasses<'py> { @@ -29,16 +125,55 @@ impl<'py> ComponentClasses<'py> { match py.import(path) { Ok(module) => { return Ok(Self { + unspecified_component: module.getattr("UnspecifiedComponent")?, + unrecognized_component: module.getattr("UnrecognizedComponent")?, + grid_connection_point: module.getattr("GridConnectionPoint")?, + meter: module.getattr("Meter")?, + + battery_inverter: module.getattr("BatteryInverter")?, + hybrid_inverter: module.getattr("HybridInverter")?, + solar_inverter: module.getattr("SolarInverter")?, + inverter: module.getattr("Inverter")?, + + converter: module.getattr("Converter")?, + + li_ion_battery: module.getattr("LiIonBattery")?, + na_ion_battery: module.getattr("NaIonBattery")?, battery: module.getattr("Battery")?, + + ac_ev_charger: module.getattr("AcEvCharger")?, + dc_ev_charger: module.getattr("DcEvCharger")?, + hybrid_ev_charger: module.getattr("HybridEvCharger")?, ev_charger: module.getattr("EvCharger")?, + + crypto_miner: module.getattr("CryptoMiner")?, + + electrolyzer: module.getattr("Electrolyzer")?, + chp: module.getattr("Chp")?, + + relay: module.getattr("Relay").ok(), + breaker: module.getattr("Breaker").ok(), + + precharger: module.getattr("Precharger")?, + + voltage_transformer: module.getattr("VoltageTransformer").ok(), + power_transformer: module.getattr("PowerTransformer").ok(), + + hvac: module.getattr("Hvac")?, + wind_turbine: module.getattr("WindTurbine")?, - battery_inverter: module.getattr("BatteryInverter")?, - solar_inverter: module.getattr("SolarInverter")?, - hybrid_inverter: module.getattr("HybridInverter")?, - unspecified_component: module.getattr("UnspecifiedComponent")?, + + steam_boiler: module.getattr("SteamBoiler")?, + + plc: module.getattr("Plc").ok(), + static_transfer_switch: module.getattr("StaticTransferSwitch").ok(), + uninterruptible_power_supply: module + .getattr("UninterruptiblePowerSupply") + .ok(), + capacitor_bank: module.getattr("CapacitorBank").ok(), }); } Err(e) => last_err = Some(e), @@ -52,52 +187,172 @@ impl<'py> ComponentClasses<'py> { } } +/// True when `object` is `class` itself or an instance of it. +fn is_class_or_instance(object: &Bound<'_, PyAny>, class: &Bound<'_, PyAny>) -> PyResult { + Ok(object.is_instance(class)? || object.is(class)) +} + +/// Same as [`is_class_or_instance`], but returns `false` when the optional +/// class wasn't resolved by [`ComponentClasses::try_new`]. +fn is_class_or_instance_opt( + object: &Bound<'_, PyAny>, + class: Option<&Bound<'_, PyAny>>, +) -> PyResult { + match class { + Some(c) => is_class_or_instance(object, c), + None => Ok(false), + } +} + pub(crate) fn category_from_python_component( py: Python<'_>, object: &Bound<'_, PyAny>, ) -> PyResult { - let comp_classes = ComponentClasses::try_new(py)?; + let cls = ComponentClasses::try_new(py)?; - if object.is_instance(&comp_classes.grid_connection_point)? - || object.is(&comp_classes.grid_connection_point) + // Order mirrors the `ComponentCategory` enum in + // `frequenz.client.microgrid.component._category`. Within each + // category, concrete subtype classes are checked before their + // abstract parent so the cg-side subtype is preserved instead + // of collapsing to `Unspecified`. cg-only categories follow at + // the end. + + // UNSPECIFIED + if is_class_or_instance(object, &cls.unspecified_component)? + || is_class_or_instance(object, &cls.unrecognized_component)? { - Ok(cg::ComponentCategory::GridConnectionPoint) - } else if object.is_instance(&comp_classes.meter)? || object.is(&comp_classes.meter) { - Ok(cg::ComponentCategory::Meter) - } else if object.is_instance(&comp_classes.battery)? || object.is(&comp_classes.battery) { - Ok(cg::ComponentCategory::Battery(cg::BatteryType::Unspecified)) - } else if object.is_instance(&comp_classes.ev_charger)? || object.is(&comp_classes.ev_charger) { - Ok(cg::ComponentCategory::EvCharger( + return Ok(cg::ComponentCategory::Unspecified); + } + + // GRID_CONNECTION_POINT + if is_class_or_instance(object, &cls.grid_connection_point)? { + return Ok(cg::ComponentCategory::GridConnectionPoint); + } + + // METER + if is_class_or_instance(object, &cls.meter)? { + return Ok(cg::ComponentCategory::Meter); + } + + // INVERTER + if is_class_or_instance(object, &cls.battery_inverter)? { + return Ok(cg::ComponentCategory::Inverter(cg::InverterType::Battery)); + } + if is_class_or_instance(object, &cls.hybrid_inverter)? { + return Ok(cg::ComponentCategory::Inverter(cg::InverterType::Hybrid)); + } + if is_class_or_instance(object, &cls.solar_inverter)? { + return Ok(cg::ComponentCategory::Inverter(cg::InverterType::Pv)); + } + if is_class_or_instance(object, &cls.inverter)? { + return Ok(cg::ComponentCategory::Inverter( + cg::InverterType::Unspecified, + )); + } + + // CONVERTER + if is_class_or_instance(object, &cls.converter)? { + return Ok(cg::ComponentCategory::Converter); + } + + // BATTERY + if is_class_or_instance(object, &cls.li_ion_battery)? { + return Ok(cg::ComponentCategory::Battery(cg::BatteryType::LiIon)); + } + if is_class_or_instance(object, &cls.na_ion_battery)? { + return Ok(cg::ComponentCategory::Battery(cg::BatteryType::NaIon)); + } + if is_class_or_instance(object, &cls.battery)? { + return Ok(cg::ComponentCategory::Battery(cg::BatteryType::Unspecified)); + } + + // EV_CHARGER + if is_class_or_instance(object, &cls.ac_ev_charger)? { + return Ok(cg::ComponentCategory::EvCharger(cg::EvChargerType::Ac)); + } + if is_class_or_instance(object, &cls.dc_ev_charger)? { + return Ok(cg::ComponentCategory::EvCharger(cg::EvChargerType::Dc)); + } + if is_class_or_instance(object, &cls.hybrid_ev_charger)? { + return Ok(cg::ComponentCategory::EvCharger(cg::EvChargerType::Hybrid)); + } + if is_class_or_instance(object, &cls.ev_charger)? { + return Ok(cg::ComponentCategory::EvCharger( cg::EvChargerType::Unspecified, - )) - } else if object.is_instance(&comp_classes.chp)? || object.is(&comp_classes.chp) { - Ok(cg::ComponentCategory::Chp) - } else if object.is_instance(&comp_classes.battery_inverter)? - || object.is(&comp_classes.battery_inverter) - { - Ok(cg::ComponentCategory::Inverter(cg::InverterType::Battery)) - } else if object.is_instance(&comp_classes.solar_inverter)? - || object.is(&comp_classes.solar_inverter) - { - Ok(cg::ComponentCategory::Inverter(cg::InverterType::Pv)) - } else if object.is_instance(&comp_classes.hybrid_inverter)? - || object.is(&comp_classes.hybrid_inverter) - { - Ok(cg::ComponentCategory::Inverter(cg::InverterType::Hybrid)) - } else if object.is_instance(&comp_classes.wind_turbine)? - || object.is(&comp_classes.wind_turbine) + )); + } + + // CRYPTO_MINER + if is_class_or_instance(object, &cls.crypto_miner)? { + return Ok(cg::ComponentCategory::CryptoMiner); + } + + // ELECTROLYZER + if is_class_or_instance(object, &cls.electrolyzer)? { + return Ok(cg::ComponentCategory::Electrolyzer); + } + + // CHP + if is_class_or_instance(object, &cls.chp)? { + return Ok(cg::ComponentCategory::Chp); + } + + // RELAY — the client's `Relay` is the protobuf BREAKER tag. + // `Breaker` is also accepted from alternative providers. + if is_class_or_instance_opt(object, cls.relay.as_ref())? + || is_class_or_instance_opt(object, cls.breaker.as_ref())? { - Ok(cg::ComponentCategory::WindTurbine) - } else if object.is_instance(&comp_classes.unspecified_component)? - || object.is(&comp_classes.unspecified_component) + return Ok(cg::ComponentCategory::Breaker); + } + + // PRECHARGER + if is_class_or_instance(object, &cls.precharger)? { + return Ok(cg::ComponentCategory::Precharger); + } + + // POWER_TRANSFORMER — `VoltageTransformer` is the deprecated + // client alias for the same protobuf POWER_TRANSFORMER tag. + if is_class_or_instance_opt(object, cls.voltage_transformer.as_ref())? + || is_class_or_instance_opt(object, cls.power_transformer.as_ref())? { - Ok(cg::ComponentCategory::Unspecified) - } else { - Err(exceptions::PyValueError::new_err(format!( - "Unsupported component category: {:?}", - object - ))) + return Ok(cg::ComponentCategory::PowerTransformer); } + + // HVAC + if is_class_or_instance(object, &cls.hvac)? { + return Ok(cg::ComponentCategory::Hvac); + } + + // WIND_TURBINE + if is_class_or_instance(object, &cls.wind_turbine)? { + return Ok(cg::ComponentCategory::WindTurbine); + } + + // STEAM_BOILER + if is_class_or_instance(object, &cls.steam_boiler)? { + return Ok(cg::ComponentCategory::SteamBoiler); + } + + // cg-only categories — no class in the microgrid client + // today, but mapped here so an alternative provider that + // exposes them just works. + if is_class_or_instance_opt(object, cls.plc.as_ref())? { + return Ok(cg::ComponentCategory::Plc); + } + if is_class_or_instance_opt(object, cls.static_transfer_switch.as_ref())? { + return Ok(cg::ComponentCategory::StaticTransferSwitch); + } + if is_class_or_instance_opt(object, cls.uninterruptible_power_supply.as_ref())? { + return Ok(cg::ComponentCategory::UninterruptiblePowerSupply); + } + if is_class_or_instance_opt(object, cls.capacitor_bank.as_ref())? { + return Ok(cg::ComponentCategory::CapacitorBank); + } + + Err(exceptions::PyValueError::new_err(format!( + "Unsupported component category: {:?}", + object + ))) } pub(crate) fn match_category( From 6006eb57ddefcbfe8ba3851bd87821aff160370b Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Thu, 7 May 2026 14:50:14 +0000 Subject: [PATCH 2/5] Add pass-through tests for Relay and Hvac Build a Grid -> ... -> SolarInverter graph with each pass-through category placed inline and assert that cg's pass-through behavior is observable from the Python API: * predecessors/successors walk past the pass-through node * pv_ac_coalesce_formula doesn't reference the pass-through Hvac is used rather than VoltageTransformer because the latter maps to cg::PowerTransformer, which may stop being pass-through once the Frequenz client gains transformer-specific handling. Signed-off-by: Sahas Subramanian --- tests/test_microgrid_component_graph.py | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index 6cf4edb..efe83e4 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -9,7 +9,9 @@ Component, ComponentConnection, GridConnectionPoint, + Hvac, Meter, + Relay, SolarInverter, WindTurbine, ) @@ -124,3 +126,94 @@ def test_wind_turbine_graph() -> None: assert graph.predecessors(ComponentId(3)) == { Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)) } + + +def test_relay_is_passthrough() -> None: + """`Relay` maps to cg's `Breaker`, a pass-through category. + + A Relay placed between a Meter and a SolarInverter should be + transparent: cg walks past it when answering `predecessors`/ + `successors`, and the PV formula references only the Meter and + the inverter. + """ + graph: microgrid_component_graph.ComponentGraph[ + Component, ComponentConnection, ComponentId + ] = microgrid_component_graph.ComponentGraph( + components={ + GridConnectionPoint( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + rated_fuse_current=100, + ), + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + Relay(id=ComponentId(3), microgrid_id=MicrogridId(1)), + SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1)), + }, + connections={ + # Grid -> Meter -> Relay -> SolarInverter + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(3)), + ComponentConnection(source=ComponentId(3), destination=ComponentId(4)), + }, + ) + + # Neighbor queries walk past the Relay. + assert graph.predecessors(ComponentId(4)) == { + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + } + assert graph.successors(ComponentId(2)) == { + SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1)), + } + # And the formula references the Meter and the inverter only -- + # the Relay (#3) does not appear. + assert ( + graph.pv_ac_coalesce_formula(pv_inverter_ids={ComponentId(4)}) + == "COALESCE(#2, #4)" + ) + + +def test_hvac_is_passthrough() -> None: + """`Hvac` maps to cg's `Hvac`, a pass-through category. + + An Hvac placed between the Grid and a Meter should be + transparent: `successors(Grid)` walks past it to the Meter, + and the PV formula references only the Meter and the inverter. + """ + graph: microgrid_component_graph.ComponentGraph[ + Component, ComponentConnection, ComponentId + ] = microgrid_component_graph.ComponentGraph( + components={ + GridConnectionPoint( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + rated_fuse_current=100, + ), + Hvac(id=ComponentId(2), microgrid_id=MicrogridId(1)), + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1)), + }, + connections={ + # Grid -> Hvac -> Meter -> SolarInverter + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(3)), + ComponentConnection(source=ComponentId(3), destination=ComponentId(4)), + }, + ) + + # Neighbor queries walk past the Hvac. + assert graph.successors(ComponentId(1)) == { + Meter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + } + assert graph.predecessors(ComponentId(3)) == { + GridConnectionPoint( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + rated_fuse_current=100, + ), + } + # And the formula references the Meter and the inverter only -- + # the Hvac (#2) does not appear. + assert ( + graph.pv_ac_coalesce_formula(pv_inverter_ids={ComponentId(4)}) + == "COALESCE(#3, #4)" + ) From a66ac48dd384faaee86cd6e439802151c16f170b Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Fri, 8 May 2026 12:15:47 +0000 Subject: [PATCH 3/5] Add component-type acceptance tests Parametrize a small graph over each Python component class the category mapping handles, and assert the instance ends up in the graph. Covers every concrete subtype 1594ad3 introduced -- not just the hardcoded leaves -- so a future regression in the mapping would fail loudly rather than only being caught once an instance flows through end-to-end. Topology per category, picking the smallest valid graph: * Battery subtypes (LiIonBattery, NaIonBattery, UnspecifiedBattery, UnrecognizedBattery): downstream of a BatteryInverter. * EV chargers (Ac/Dc/Hybrid/Unspecified/Unrecognized) and plain leaf consumers (Chp, SteamBoiler): leaf below a Meter. * Battery- and Hybrid-Inverters: between a Meter and a Battery. * Pass-through categories (Converter, CryptoMiner, Electrolyzer, Precharger, VoltageTransformer): inline with a SolarInverter consumer downstream. Plus a paired rejection test for the classes whose mapping yields an Unspecified category, which the cg crate refuses at construction: * cg::Unspecified -- UnspecifiedComponent, UnrecognizedComponent. * cg::Inverter(Unspecified) -- UnspecifiedInverter, UnrecognizedInverter (rejected under the default allow_unspecified_inverters=False). GridConnectionPoint, Meter, SolarInverter, WindTurbine, Relay, and Hvac are exercised by the existing topology-specific tests and so aren't repeated here. Filtering via matching_types= isn't asserted because the binding's class-object resolution doesn't follow the Python class hierarchy for subtypes that aren't hardcoded in ComponentClasses; plain set membership in graph.components() is enough to prove acceptance. Per review feedback on PR #72. Signed-off-by: Sahas Subramanian --- tests/test_microgrid_component_graph.py | 199 ++++++++++++++++++++++++ 1 file changed, 199 insertions(+) diff --git a/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index efe83e4..9f7cbff 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -3,16 +3,42 @@ """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 ( + AcEvCharger, + Battery, + BatteryInverter, + Chp, Component, ComponentConnection, + Converter, + CryptoMiner, + DcEvCharger, + Electrolyzer, GridConnectionPoint, Hvac, + HybridEvCharger, + HybridInverter, + LiIonBattery, Meter, + NaIonBattery, + Precharger, Relay, SolarInverter, + SteamBoiler, + UnrecognizedBattery, + UnrecognizedComponent, + UnrecognizedEvCharger, + UnrecognizedInverter, + UnspecifiedBattery, + UnspecifiedComponent, + UnspecifiedEvCharger, + UnspecifiedInverter, + VoltageTransformer, WindTurbine, ) @@ -217,3 +243,176 @@ def test_hvac_is_passthrough() -> None: graph.pv_ac_coalesce_formula(pv_inverter_ids={ComponentId(4)}) == "COALESCE(#3, #4)" ) + + +# Pass-through categories from the microgrid client. +_PASS_THROUGH_CLASSES = ( + Converter, + CryptoMiner, + Electrolyzer, + Hvac, + Precharger, + Relay, + VoltageTransformer, +) + + +def _build_target(cls: type[Component], cid: int) -> Component: + """Construct a `cls` instance, supplying ctor args its variants need.""" + extra: dict[str, Any] = {} + if cls is UnrecognizedComponent: + extra["category"] = 999 + elif cls in (UnrecognizedBattery, UnrecognizedEvCharger, UnrecognizedInverter): + extra["type"] = 999 + elif cls is VoltageTransformer: + extra["primary_voltage"] = 20_000.0 + extra["secondary_voltage"] = 400.0 + return cls(id=ComponentId(cid), microgrid_id=MicrogridId(1), **extra) + + +@pytest.mark.parametrize( + "component_type", + [ + # Pass-through categories: transparent to the graph traversal until + # explicit handling is added for them. + Converter, + CryptoMiner, + Electrolyzer, + Hvac, + Precharger, + Relay, + VoltageTransformer, + # Battery subtypes + LiIonBattery, + NaIonBattery, + UnspecifiedBattery, + UnrecognizedBattery, + # EV chargers + AcEvCharger, + DcEvCharger, + HybridEvCharger, + UnspecifiedEvCharger, + UnrecognizedEvCharger, + # Inverters + BatteryInverter, + HybridInverter, + SolarInverter, + # Other producers and/or consumers. + Chp, + SteamBoiler, + ], +) +def test_component_type_is_accepted(component_type: type[Component]) -> None: + """Each mapped component class can be added to a graph. + + Builds the smallest topology that's valid for the category and + asserts the instance ends up in the graph. Covers every concrete + class the category mapping handles, except the ones already + exercised by the topology-specific tests above (Grid/Meter/ + SolarInverter/WindTurbine/Relay/Hvac). + """ + grid = GridConnectionPoint( + id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=100 + ) + meter = Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)) + + if issubclass(component_type, Battery): + # Grid -> Meter -> BatteryInverter -> battery_under_test + target = _build_target(component_type, cid=4) + components: set[Component] = { + grid, + meter, + BatteryInverter(id=ComponentId(3), microgrid_id=MicrogridId(1)), + target, + } + connections = { + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(3)), + ComponentConnection(source=ComponentId(3), destination=ComponentId(4)), + } + elif component_type in (BatteryInverter, HybridInverter): + # Grid -> Meter -> inverter_under_test -> LiIonBattery + target = _build_target(component_type, cid=3) + components = { + grid, + meter, + target, + LiIonBattery(id=ComponentId(4), microgrid_id=MicrogridId(1)), + } + connections = { + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(3)), + ComponentConnection(source=ComponentId(3), destination=ComponentId(4)), + } + elif component_type in _PASS_THROUGH_CLASSES: + # Grid -> Meter -> pass_through_under_test -> SolarInverter + target = _build_target(component_type, cid=3) + components = { + grid, + meter, + target, + SolarInverter(id=ComponentId(4), microgrid_id=MicrogridId(1)), + } + connections = { + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(3)), + ComponentConnection(source=ComponentId(3), destination=ComponentId(4)), + } + else: + # Grid -> Meter -> leaf (EvChargers, Chp, SteamBoiler, SolarInverter). + target = _build_target(component_type, cid=3) + components = {grid, meter, target} + connections = { + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(3)), + } + + graph: microgrid_component_graph.ComponentGraph[ + Component, ComponentConnection, ComponentId + ] = microgrid_component_graph.ComponentGraph( + components=components, + connections=connections, + ) + assert target in graph.components() + + +@pytest.mark.parametrize( + "component_type", + [ + # Map to cg::Unspecified, which the graph rejects up-front. + UnspecifiedComponent, + UnrecognizedComponent, + # Map to cg::Inverter(Unspecified), which the graph also rejects + # under the default config (allow_unspecified_inverters=False). + UnspecifiedInverter, + UnrecognizedInverter, + ], +) +def test_unspecified_component_type_is_rejected( + component_type: type[Component], +) -> None: + """Classes whose mapping yields an Unspecified category fail at graph creation. + + The category mapping accepts these classes -- so the user-facing + error is a topology-level one rather than ``Unsupported component + category`` -- but the underlying crate refuses to construct a + graph that contains an Unspecified component or Unspecified + inverter. + """ + target = _build_target(component_type, cid=3) + with pytest.raises(microgrid_component_graph.InvalidGraphError): + microgrid_component_graph.ComponentGraph( + components={ + GridConnectionPoint( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + rated_fuse_current=100, + ), + Meter(id=ComponentId(2), microgrid_id=MicrogridId(1)), + target, + }, + connections={ + ComponentConnection(source=ComponentId(1), destination=ComponentId(2)), + ComponentConnection(source=ComponentId(2), destination=ComponentId(3)), + }, + ) From 2b6e763a5462a0af06bff57d507f83cdeee942b9 Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Fri, 8 May 2026 12:28:17 +0200 Subject: [PATCH 4/5] Update release notes Signed-off-by: Sahas Subramanian --- RELEASE_NOTES.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5c3581a..dadc45c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,17 +1,9 @@ # Frequenz Microgrid Component Graph Library Release Notes -## Summary - - - ## Upgrading - +- The `microgrid` extra now requires `frequenz-client-microgrid >= 0.18.3` (was `>= 0.18.0`) so that the `SteamBoiler` component class is available. ## New Features - - -## Bug Fixes - - +- `ComponentGraph` now accepts every component class shipped by `frequenz.client.microgrid.component`. Battery and EV-charger subtypes (`LiIonBattery`, `NaIonBattery`, `AcEvCharger`, `DcEvCharger`, `HybridEvCharger`) are mapped to their cg-side subtype variant instead of collapsing to `Unspecified`. Pass-through categories (`Converter`, `CryptoMiner`, `Electrolyzer`, `Hvac`, `Precharger`, `Relay`, `VoltageTransformer`), plus `SteamBoiler` and `UnrecognizedComponent`, are now mapped where they previously raised `ValueError("Unsupported component category: …")`. From 41b363b10f9e66679b79867c53c63ea1a58b847d Mon Sep 17 00:00:00 2001 From: Sahas Subramanian Date: Fri, 8 May 2026 12:27:14 +0200 Subject: [PATCH 5/5] Bump version to 0.4.1 Signed-off-by: Sahas Subramanian --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb17f75..eb726b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,7 +32,7 @@ dependencies = [ [[package]] name = "frequenz-microgrid-component-graph-python-bindings" -version = "0.4.0" +version = "0.4.1" dependencies = [ "frequenz-microgrid-component-graph", "pyo3", diff --git a/Cargo.toml b/Cargo.toml index 7687c1d..61a18be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frequenz-microgrid-component-graph-python-bindings" -version = "0.4.0" +version = "0.4.1" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html