diff --git a/Cargo.lock b/Cargo.lock index b65ec46..8f9023e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,9 +22,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "frequenz-microgrid-component-graph" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3e7cc6d7f8b142a41cd40ada08e2c94334a64adcfa9a6162dd964a590d7305" +checksum = "e2ae40700292e149155d32a2267f93c48fdf1749a2176bbd7f4b0b0fa643396c" dependencies = [ "petgraph", "tracing", diff --git a/Cargo.toml b/Cargo.toml index a03c752..ba3e376 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] pyo3 = "0.27.1" -frequenz-microgrid-component-graph = "0.4" +frequenz-microgrid-component-graph = "0.5" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 40dd2cc..95a91a9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -3,3 +3,9 @@ ## Upgrading - This release updates the `frequenz-microgrid-component-graph` rust crate version to 0.4. + +- This release updates the `frequenz-microgrid-component-graph` rust crate version to 0.5. `ComponentGraphConfig` is restructured to match: the six per-category `prefer_X_in_Y_formula` flags are replaced by a global `prefer_meters_in_component_formulas` plus per-formula overrides via the new `FormulaOverrides` class. Also exposes the new `steam_boiler_formula` method, and renames `battery_coalesce_formula` / `pv_coalesce_formula` to `battery_ac_coalesce_formula` / `pv_ac_coalesce_formula`. + +- `ComponentGraphConfig.__init__` is now declared as keyword-only in the type stubs (the runtime was already keyword-only, so positional calls were already failing at runtime). + +- The per-category preference flags inverted polarity: where the old flags selected the *device*, the new override entries select the *meter*. E.g. `ComponentGraphConfig(prefer_inverters_in_pv_formula=True)` becomes `ComponentGraphConfig(formula_overrides=FormulaOverrides(prefer_meters_in_pv_formula=False))`. diff --git a/python/frequenz/microgrid_component_graph/__init__.py b/python/frequenz/microgrid_component_graph/__init__.py index d09315e..e6520cc 100644 --- a/python/frequenz/microgrid_component_graph/__init__.py +++ b/python/frequenz/microgrid_component_graph/__init__.py @@ -7,6 +7,7 @@ ComponentGraph, ComponentGraphConfig, FormulaGenerationError, + FormulaOverrides, InvalidGraphError, ) @@ -14,5 +15,6 @@ "ComponentGraph", "ComponentGraphConfig", "FormulaGenerationError", + "FormulaOverrides", "InvalidGraphError", ] diff --git a/python/frequenz/microgrid_component_graph/__init__.pyi b/python/frequenz/microgrid_component_graph/__init__.pyi index 1e2a668..e3d17d0 100644 --- a/python/frequenz/microgrid_component_graph/__init__.pyi +++ b/python/frequenz/microgrid_component_graph/__init__.pyi @@ -19,16 +19,14 @@ class ComponentGraphConfig: def __init__( self, + *, allow_component_validation_failures: bool = False, allow_unconnected_components: bool = False, allow_unspecified_inverters: bool = False, disable_fallback_components: bool = False, include_phantom_loads_in_consumer_formula: bool = False, - prefer_inverters_in_battery_formula: bool = False, - prefer_inverters_in_pv_formula: bool = False, - prefer_chp_in_chp_formula: bool = False, - prefer_ev_chargers_in_ev_formula: bool = False, - prefer_wind_turbines_in_wind_formula: bool = False, + prefer_meters_in_component_formulas: bool = True, + formula_overrides: FormulaOverrides | None = None, ) -> None: """Initialize this instance. @@ -51,26 +49,47 @@ class ComponentGraphConfig: the measurements of successor meters from the measurements of their predecessor meters. When `false`, consumer formula is generated by excluding production and battery components from the grid measurements. - prefer_inverters_in_battery_formula: Whether to prefer battery inverters - when generating Battery formulas. When this is `true`, battery inverters - will be the primary source and battery meters will be secondary. When - `false`, battery meters will be the primary source. - prefer_inverters_in_pv_formula: Whether to prefer PV inverters when - generating PV formulas. When this is `true`, PV inverters will be the - primary source and PV meters will be secondary. When `false`, PV meters - will be the primary source. - prefer_chp_in_chp_formula: Whether to prefer CHP when generating CHP - formulas. When this is `true`, CHPs will be the primary source and CHP - meters will be secondary. When `false`, CHP meters will be the primary - source. - prefer_ev_chargers_in_ev_formula: Whether to prefer EV chargers when - generating EV charger formulas. When this is `true`, EV chargers will be - the primary source and EV meters will be secondary. When `false`, EV - meters will be the primary source. - prefer_wind_turbines_in_wind_formula: Whether to prefer wind turbines when - generating wind turbine formulas. When this is `true`, wind turbines - will be the primary source and wind meters will be secondary. When - `false`, wind meters will be the primary source. + prefer_meters_in_component_formulas: Default policy for the per-category + formulas. When `True` (the default), the meter measurement is the + primary source and the device measurement is the fallback for + `battery_formula`, `chp_formula`, `pv_formula`, `wind_turbine_formula`, + `ev_charger_formula`, and `steam_boiler_formula`. When `False`, the + device is primary and the meter is the fallback. Has no effect on + `grid_formula`, `consumer_formula`, `producer_formula`, or any of the + coalesce formulas. + formula_overrides: Per-formula overrides for the meter/device preference; + see `FormulaOverrides`. Each entry, when set, takes precedence over + `prefer_meters_in_component_formulas` for that formula. + """ + +class FormulaOverrides: + """Per-formula overrides for the meter/device preference. + + Each parameter is `None` by default, meaning the corresponding formula + follows the global `prefer_meters_in_component_formulas` setting on + `ComponentGraphConfig`. Setting `True` forces the meter as primary + for that formula; `False` forces the device. + """ + + def __init__( + self, + *, + prefer_meters_in_pv_formula: bool | None = None, + prefer_meters_in_battery_formula: bool | None = None, + prefer_meters_in_chp_formula: bool | None = None, + prefer_meters_in_ev_charger_formula: bool | None = None, + prefer_meters_in_wind_turbine_formula: bool | None = None, + prefer_meters_in_steam_boiler_formula: bool | None = None, + ) -> None: + """Initialize this instance. + + Args: + prefer_meters_in_pv_formula: Override for `pv_formula`. + prefer_meters_in_battery_formula: Override for `battery_formula`. + prefer_meters_in_chp_formula: Override for `chp_formula`. + prefer_meters_in_ev_charger_formula: Override for `ev_charger_formula`. + prefer_meters_in_wind_turbine_formula: Override for `wind_turbine_formula`. + prefer_meters_in_steam_boiler_formula: Override for `steam_boiler_formula`. """ class ComponentIdProtocol(Protocol): @@ -341,6 +360,22 @@ class ComponentGraph(Generic[ComponentT, ConnectionT, ComponentIdT]): are not wind turbines. """ + def steam_boiler_formula(self, steam_boiler_ids: Set[ComponentIdT] | None) -> str: + """Generate the steam boiler formula for this component graph. + + Args: + steam_boiler_ids: The set of steam boiler component IDs to include in + the formula. If `None`, all steam boilers in the graph will be + included. + + Returns: + The steam boiler formula as a string. + + Raises: + FormulaGenerationError: if the given component IDs don't exist or + are not steam boilers. + """ + def grid_coalesce_formula(self) -> str: """Generate the grid coalesce formula for this component graph. @@ -348,7 +383,7 @@ class ComponentGraph(Generic[ComponentT, ConnectionT, ComponentIdT]): The grid coalesced formula as a string. """ - def battery_coalesce_formula(self, battery_ids: Set[ComponentIdT] | None) -> str: + def battery_ac_coalesce_formula(self, battery_ids: Set[ComponentIdT] | None) -> str: """Generate the battery coalesce formula for this component graph. Args: @@ -364,7 +399,7 @@ class ComponentGraph(Generic[ComponentT, ConnectionT, ComponentIdT]): are not batteries. """ - def pv_coalesce_formula(self, pv_inverter_ids: Set[ComponentIdT] | None) -> str: + def pv_ac_coalesce_formula(self, pv_inverter_ids: Set[ComponentIdT] | None) -> str: """Generate the PV coalesce formula for this component graph. Args: diff --git a/src/graph.rs b/src/graph.rs index c75f2ba..841093f 100644 --- a/src/graph.rs +++ b/src/graph.rs @@ -32,11 +32,8 @@ impl ComponentGraphConfig { allow_unspecified_inverters = false, disable_fallback_components = false, include_phantom_loads_in_consumer_formula = false, - prefer_inverters_in_battery_formula = false, - prefer_inverters_in_pv_formula = false, - prefer_chp_in_chp_formula = false, - prefer_ev_chargers_in_ev_formula = false, - prefer_wind_turbines_in_wind_formula = false, + prefer_meters_in_component_formulas = true, + formula_overrides = None, ))] fn new( allow_component_validation_failures: bool, @@ -44,25 +41,72 @@ impl ComponentGraphConfig { allow_unspecified_inverters: bool, disable_fallback_components: bool, include_phantom_loads_in_consumer_formula: bool, - prefer_inverters_in_battery_formula: bool, - prefer_inverters_in_pv_formula: bool, - prefer_chp_in_chp_formula: bool, - prefer_ev_chargers_in_ev_formula: bool, - prefer_wind_turbines_in_wind_formula: bool, + prefer_meters_in_component_formulas: bool, + formula_overrides: Option, ) -> Self { + let mut builder = cg::ComponentGraphConfig::builder() + .allow_component_validation_failures(allow_component_validation_failures) + .allow_unconnected_components(allow_unconnected_components) + .allow_unspecified_inverters(allow_unspecified_inverters) + .disable_fallback_components(disable_fallback_components) + .include_phantom_loads_in_consumer_formula(include_phantom_loads_in_consumer_formula) + .prefer_meters_in_component_formulas(prefer_meters_in_component_formulas); + if let Some(overrides) = formula_overrides { + builder = builder.formula_overrides(overrides.overrides); + } ComponentGraphConfig { - config: cg::ComponentGraphConfig { - allow_component_validation_failures, - allow_unconnected_components, - allow_unspecified_inverters, - disable_fallback_components, - include_phantom_loads_in_consumer_formula, - prefer_inverters_in_battery_formula, - prefer_inverters_in_pv_formula, - prefer_chp_in_chp_formula, - prefer_ev_chargers_in_ev_formula, - prefer_wind_turbines_in_wind_formula, - }, + config: builder.build(), + } + } +} + +#[pyclass(subclass)] +#[derive(Clone, Default, Debug)] +pub struct FormulaOverrides { + overrides: cg::FormulaOverrides, +} + +#[pymethods] +impl FormulaOverrides { + #[new] + #[pyo3(signature = ( + *, + prefer_meters_in_pv_formula = None, + prefer_meters_in_battery_formula = None, + prefer_meters_in_chp_formula = None, + prefer_meters_in_ev_charger_formula = None, + prefer_meters_in_wind_turbine_formula = None, + prefer_meters_in_steam_boiler_formula = None, + ))] + fn new( + prefer_meters_in_pv_formula: Option, + prefer_meters_in_battery_formula: Option, + prefer_meters_in_chp_formula: Option, + prefer_meters_in_ev_charger_formula: Option, + prefer_meters_in_wind_turbine_formula: Option, + prefer_meters_in_steam_boiler_formula: Option, + ) -> Self { + let mut builder = cg::FormulaOverrides::builder(); + if let Some(v) = prefer_meters_in_pv_formula { + builder = builder.prefer_meters_in_pv_formula(v); + } + if let Some(v) = prefer_meters_in_battery_formula { + builder = builder.prefer_meters_in_battery_formula(v); + } + if let Some(v) = prefer_meters_in_chp_formula { + builder = builder.prefer_meters_in_chp_formula(v); + } + if let Some(v) = prefer_meters_in_ev_charger_formula { + builder = builder.prefer_meters_in_ev_charger_formula(v); + } + if let Some(v) = prefer_meters_in_wind_turbine_formula { + builder = builder.prefer_meters_in_wind_turbine_formula(v); + } + if let Some(v) = prefer_meters_in_steam_boiler_formula { + builder = builder.prefer_meters_in_steam_boiler_formula(v); + } + FormulaOverrides { + overrides: builder.build(), } } } @@ -308,6 +352,18 @@ impl ComponentGraph { .map_err(|e| PyErr::new::(e.to_string())) } + #[pyo3(signature = (steam_boiler_ids=None))] + fn steam_boiler_formula( + &self, + py: Python<'_>, + steam_boiler_ids: Option>, + ) -> PyResult { + self.graph + .steam_boiler_formula(extract_ids(py, steam_boiler_ids)?) + .map(|f| f.to_string()) + .map_err(|e| PyErr::new::(e.to_string())) + } + fn grid_coalesce_formula(&self) -> PyResult { self.graph .grid_coalesce_formula() diff --git a/src/lib.rs b/src/lib.rs index 116a3f7..cf7e117 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,4 +34,6 @@ mod _component_graph { use crate::graph::ComponentGraph; #[pymodule_export] use crate::graph::ComponentGraphConfig; + #[pymodule_export] + use crate::graph::FormulaOverrides; } diff --git a/tests/test_formula_preferences.py b/tests/test_formula_preferences.py new file mode 100644 index 0000000..5013cec --- /dev/null +++ b/tests/test_formula_preferences.py @@ -0,0 +1,253 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Behavioral tests for `ComponentGraphConfig` formula preferences. + +These tests build a small, controllable graph (Grid -> Meter -> Device) +for each per-category formula method and assert the actual formula +output for the four meter/device-preference combinations: + + * default config -> meter primary + * global False -> device primary + * per-formula override = True -> meter primary (override wins) + * per-formula override = False -> device primary (override wins) + +The drift test in `test_stub_drift.py` only checks signatures; this +file catches actual mis-wiring -- a swapped override, an inverted +boolean, or a missing builder call would change the formula output. +""" + +from collections.abc import Callable +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, + BatteryInverter, + Chp, + ComponentConnection, + GridConnectionPoint, + LiIonBattery, + Meter, + SolarInverter, + WindTurbine, +) + +from frequenz.microgrid_component_graph import ( + ComponentGraph, + ComponentGraphConfig, + FormulaGenerationError, + FormulaOverrides, +) + +_MGRID = MicrogridId(1) + +# In every per-category topology built below, component #2 is the meter +# and #3 is the device that appears in the formula. +_METER_PRIMARY = "COALESCE(#2, #3, 0.0)" +_DEVICE_PRIMARY = "COALESCE(#3, #2, 0.0)" + +# Each per-category graph builder takes an optional `config` and returns a +# graph rooted at the same Grid -> Meter pair, so the assertion strings +# above are the same across categories. +GraphBuilder = Callable[[ComponentGraphConfig | None], ComponentGraph[Any, Any, Any]] + + +def _conn(source: int, dest: int) -> ComponentConnection: + return ComponentConnection( + source=ComponentId(source), destination=ComponentId(dest) + ) + + +def _grid() -> GridConnectionPoint: + return GridConnectionPoint( + id=ComponentId(1), microgrid_id=_MGRID, rated_fuse_current=100 + ) + + +def _meter() -> Meter: + return Meter(id=ComponentId(2), microgrid_id=_MGRID) + + +def _pv_graph( + config: ComponentGraphConfig | None = None, +) -> ComponentGraph[Any, Any, Any]: + return ComponentGraph( + components={ + _grid(), + _meter(), + SolarInverter(id=ComponentId(3), microgrid_id=_MGRID), + }, + connections={_conn(1, 2), _conn(2, 3)}, + config=config or ComponentGraphConfig(), + ) + + +def _wind_graph( + config: ComponentGraphConfig | None = None, +) -> ComponentGraph[Any, Any, Any]: + return ComponentGraph( + components={ + _grid(), + _meter(), + WindTurbine(id=ComponentId(3), microgrid_id=_MGRID), + }, + connections={_conn(1, 2), _conn(2, 3)}, + config=config or ComponentGraphConfig(), + ) + + +def _battery_graph( + config: ComponentGraphConfig | None = None, +) -> ComponentGraph[Any, Any, Any]: + # Grid -> Meter -> BatteryInverter -> Battery; the formula references + # the inverter (#3) as the device, the battery (#4) doesn't appear. + return ComponentGraph( + components={ + _grid(), + _meter(), + BatteryInverter(id=ComponentId(3), microgrid_id=_MGRID), + LiIonBattery(id=ComponentId(4), microgrid_id=_MGRID), + }, + connections={_conn(1, 2), _conn(2, 3), _conn(3, 4)}, + config=config or ComponentGraphConfig(), + ) + + +def _chp_graph( + config: ComponentGraphConfig | None = None, +) -> ComponentGraph[Any, Any, Any]: + return ComponentGraph( + components={ + _grid(), + _meter(), + Chp(id=ComponentId(3), microgrid_id=_MGRID), + }, + connections={_conn(1, 2), _conn(2, 3)}, + config=config or ComponentGraphConfig(), + ) + + +def _ev_graph( + config: ComponentGraphConfig | None = None, +) -> ComponentGraph[Any, Any, Any]: + return ComponentGraph( + components={ + _grid(), + _meter(), + AcEvCharger(id=ComponentId(3), microgrid_id=_MGRID), + }, + connections={_conn(1, 2), _conn(2, 3)}, + config=config or ComponentGraphConfig(), + ) + + +_CATEGORIES = [ + pytest.param(_pv_graph, "pv_formula", "prefer_meters_in_pv_formula", id="pv"), + pytest.param( + _wind_graph, + "wind_turbine_formula", + "prefer_meters_in_wind_turbine_formula", + id="wind_turbine", + ), + pytest.param( + _battery_graph, + "battery_formula", + "prefer_meters_in_battery_formula", + id="battery", + ), + pytest.param(_chp_graph, "chp_formula", "prefer_meters_in_chp_formula", id="chp"), + pytest.param( + _ev_graph, + "ev_charger_formula", + "prefer_meters_in_ev_charger_formula", + id="ev_charger", + ), +] + + +@pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) +def test_default_config_prefers_meter( + build_graph: GraphBuilder, + method: str, + override_field: str, # pylint: disable=unused-argument +) -> None: + """Default config selects the meter as the primary source.""" + formula = getattr(build_graph(None), method)(None) + assert formula == _METER_PRIMARY + + +@pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) +def test_global_false_prefers_device( + build_graph: GraphBuilder, + method: str, + override_field: str, # pylint: disable=unused-argument +) -> None: + """Setting `prefer_meters_in_component_formulas=False` selects the device.""" + config = ComponentGraphConfig(prefer_meters_in_component_formulas=False) + formula = getattr(build_graph(config), method)(None) + assert formula == _DEVICE_PRIMARY + + +@pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) +def test_override_true_wins_over_global_false( + build_graph: GraphBuilder, method: str, override_field: str +) -> None: + """A `True` per-formula override flips back to meter despite a `False` global.""" + config = ComponentGraphConfig( + prefer_meters_in_component_formulas=False, + formula_overrides=FormulaOverrides(**{override_field: True}), + ) + formula = getattr(build_graph(config), method)(None) + assert formula == _METER_PRIMARY + + +@pytest.mark.parametrize("build_graph,method,override_field", _CATEGORIES) +def test_override_false_wins_over_global_true( + build_graph: GraphBuilder, method: str, override_field: str +) -> None: + """A `False` per-formula override flips to device despite a `True` global.""" + config = ComponentGraphConfig( + prefer_meters_in_component_formulas=True, + formula_overrides=FormulaOverrides(**{override_field: False}), + ) + formula = getattr(build_graph(config), method)(None) + assert formula == _DEVICE_PRIMARY + + +# `steam_boiler_formula` cannot be exercised end-to-end because the +# upstream `frequenz.client.microgrid` Python package does not currently +# expose a `SteamBoiler` component class, so a graph that contains a +# steam boiler cannot be constructed from Python yet. The argument-handling +# edge cases are still meaningful and worth pinning down. + + +def _empty_graph() -> ComponentGraph[Any, Any, Any]: + return ComponentGraph( + components={_grid(), _meter()}, + connections={_conn(1, 2)}, + ) + + +def test_steam_boiler_formula_with_no_ids_returns_zero() -> None: + """`steam_boiler_formula(None)` on a graph with no steam boilers is `0.0`.""" + assert _empty_graph().steam_boiler_formula(None) == "0.0" + + +def test_steam_boiler_formula_with_empty_set_returns_zero() -> None: + """`steam_boiler_formula(set())` short-circuits to `0.0`.""" + assert _empty_graph().steam_boiler_formula(set()) == "0.0" + + +def test_steam_boiler_formula_unknown_id_raises() -> None: + """An ID that doesn't exist in the graph raises `FormulaGenerationError`.""" + with pytest.raises(FormulaGenerationError, match="999"): + _empty_graph().steam_boiler_formula({ComponentId(999)}) + + +def test_steam_boiler_formula_wrong_category_id_raises() -> None: + """An ID that exists but isn't a steam boiler raises `FormulaGenerationError`.""" + with pytest.raises(FormulaGenerationError, match="not a steam boiler"): + _empty_graph().steam_boiler_formula({ComponentId(2)}) diff --git a/tests/test_stub_drift.py b/tests/test_stub_drift.py new file mode 100644 index 0000000..32b7354 --- /dev/null +++ b/tests/test_stub_drift.py @@ -0,0 +1,85 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Catches drift between `__init__.pyi` stubs and the compiled bindings. + +The stub file is hand-written, so a `#[pymethod]` rename or a new +`#[pyo3(signature = ...)]` parameter on the Rust side has to be mirrored +manually. These tests fail loudly when that mirror lapses. +""" + +import ast +import inspect +from pathlib import Path + +import frequenz.microgrid_component_graph as mcg + +_PUBLIC_RUNTIME_CLASSES = ( + mcg.ComponentGraph, + mcg.ComponentGraphConfig, + mcg.FormulaOverrides, +) + +_STUB_PATH = Path(mcg.__file__).parent / "__init__.pyi" + + +def _parse_stub_classes() -> dict[str, dict[str, list[str]]]: + """Parse the stub into ``{ClassName: {method_name: [param_name, ...]}}``.""" + tree = ast.parse(_STUB_PATH.read_text()) + out: dict[str, dict[str, list[str]]] = {} + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + methods: dict[str, list[str]] = {} + for item in node.body: + if not isinstance(item, ast.FunctionDef): + continue + args = item.args + methods[item.name] = [a.arg for a in args.args] + [ + a.arg for a in args.kwonlyargs + ] + out[node.name] = methods + return out + + +def test_stub_and_runtime_methods_agree() -> None: + """Public method names in the stub must match those on the runtime class.""" + stub = _parse_stub_classes() + for cls in _PUBLIC_RUNTIME_CLASSES: + stub_methods = { + name for name in stub.get(cls.__name__, {}) if not name.startswith("_") + } + runtime_methods = { + name + for name in dir(cls) + if not name.startswith("_") and callable(getattr(cls, name)) + } + assert stub_methods == runtime_methods, ( + f"{cls.__name__} method drift:\n" + f" in stub but not runtime: {sorted(stub_methods - runtime_methods)}\n" + f" in runtime but not stub: {sorted(runtime_methods - stub_methods)}" + ) + + +def test_stub_and_runtime_init_params_agree() -> None: + """`__init__` parameter names in the stub must match the runtime signature. + + Skips classes where the runtime signature is not introspectable + (pyo3 fails to expose one when ``text_signature`` contains an + expression it can't parse). + """ + stub = _parse_stub_classes() + for cls in _PUBLIC_RUNTIME_CLASSES: + stub_params = [ + p for p in stub.get(cls.__name__, {}).get("__init__", []) if p != "self" + ] + try: + runtime_sig = inspect.signature(cls) + except ValueError: + continue + runtime_params = list(runtime_sig.parameters.keys()) + assert stub_params == runtime_params, ( + f"{cls.__name__}.__init__ param drift:\n" + f" stub: {stub_params}\n" + f" runtime: {runtime_params}" + )