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 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: …")`. 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( diff --git a/tests/test_microgrid_component_graph.py b/tests/test_microgrid_component_graph.py index 6cf4edb..9f7cbff 100644 --- a/tests/test_microgrid_component_graph.py +++ b/tests/test_microgrid_component_graph.py @@ -3,14 +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, ) @@ -124,3 +152,267 @@ 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)" + ) + + +# 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)), + }, + )