diff --git a/README.md b/README.md index 9e84c67..3a6f34d 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,15 @@ Optional prefix formatting: gridpool-cli print-formulas --prefix "{microgrid_id}.{component}" ``` +The per-category formulas (`pv`, `battery`, `chp`, `ev`) read the component +first and use the meter as the fallback. Use +`--prefer-meters-in-component-formulas` for the opposite order, which is what +versions before component graph v0.5.0 produced: + +```bash +gridpool-cli print-formulas --prefer-meters-in-component-formulas +``` + ### Render component graph Rendering requires optional dependencies. Install with: @@ -94,6 +103,14 @@ If no microgrid IDs are given, they are taken from the supplied files: gridpool-cli generate-config --override existing.toml > microgrid.toml ``` +`--prefer-meters-in-component-formulas` works here too, so a regenerated +config can keep the meter-first order of the per-category formulas: + +```bash +gridpool-cli generate-config \ + --prefer-meters-in-component-formulas > microgrid.toml +``` + ## Contributing If you want to know how to build this project and contribute to it, please diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d466455..7db6201 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,15 +2,18 @@ ## Summary - +This release updates the microgrid component graph library to v0.5.0 and adds a way to configure how the component graph is built. ## Upgrading - +* The per-category formulas (`pv`, `battery`, `chp`, `ev`) now read the component first and use the meter as the fallback. This is the opposite of the old order, and it changes the generated config. Regenerate stored configs with this release and review the diff. +* To keep the old order, pass `ComponentGraphConfig(prefer_meters_in_component_formulas=True)` to `load_configs`, `load_configs_from_api` or `ComponentGraphGenerator`, or use the new `--prefer-meters-in-component-formulas` flag of the CLI. +* The update brings more changes, for example to the `consumption` formula and to the graph validation error messages. See the [v0.5.0 release notes](https://github.com/frequenz-floss/frequenz-microgrid-component-graph-python/releases/tag/v0.5.0) of the component graph library for the full list. ## New Features - +* `load_configs` and `load_configs_from_api` take a `component_graph_config` argument, and `ComponentGraphGenerator` takes it as `config`. It controls how the component graph is built and how its formulas are generated. `ComponentGraphConfig` and `FormulaOverrides` are re-exported from `frequenz.gridpool` and `frequenz.gridpool.config`. +* The `generate-config` and `print-formulas` CLI commands take a `--prefer-meters-in-component-formulas` flag, which reads the meter before the component in the per-category formulas. ## Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 33508c7..872330c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ requires-python = ">= 3.11, < 4" dependencies = [ "marshmallow-dataclass >= 8.7.1, < 9", "typing-extensions >= 4.14.1, < 5", - "frequenz-microgrid-component-graph >= 0.4.0, < 0.5", + "frequenz-microgrid-component-graph >= 0.5.0, < 0.6", "frequenz-client-assets >= 0.3.1, < 0.4", ] dynamic = ["version"] @@ -95,7 +95,9 @@ dev-pytest = [ "pytest-mock == 3.15.1", "pytest-asyncio == 1.4.0", "async-solipsism == 0.9", - "frequenz-gridpool[cli]", # The tests cover the cli package + # The tests cover the cli package and the graph renderer. render-graph + # already pulls in cli. + "frequenz-gridpool[render-graph]", ] dev = [ "frequenz-gridpool[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest,render-graph]", diff --git a/src/frequenz/gridpool/__init__.py b/src/frequenz/gridpool/__init__.py index 10dc0b9..fa68cdb 100644 --- a/src/frequenz/gridpool/__init__.py +++ b/src/frequenz/gridpool/__init__.py @@ -3,6 +3,8 @@ """High-level interface to grid pools for the Frequenz platform.""" +from frequenz.microgrid_component_graph import ComponentGraphConfig, FormulaOverrides + from ._graph_generator import ComponentGraphGenerator from .config import ( Metadata, @@ -15,7 +17,9 @@ ) __all__ = [ + "ComponentGraphConfig", "ComponentGraphGenerator", + "FormulaOverrides", "Metadata", "MicrogridConfig", "load_configs", diff --git a/src/frequenz/gridpool/_graph_generator.py b/src/frequenz/gridpool/_graph_generator.py index 0430960..1b6dc3e 100644 --- a/src/frequenz/gridpool/_graph_generator.py +++ b/src/frequenz/gridpool/_graph_generator.py @@ -26,7 +26,7 @@ ) from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId -from frequenz.microgrid_component_graph import ComponentGraph +from frequenz.microgrid_component_graph import ComponentGraph, ComponentGraphConfig _logger = logging.getLogger(__name__) @@ -42,14 +42,20 @@ class ComponentGraphGenerator: def __init__( self, client: AssetsApiClient, + config: ComponentGraphConfig | None = None, ) -> None: """Initialize this instance. Args: client: The Assets API client to use for fetching components and connections. + config: How to build the graph and generate its formulas. See + `ComponentGraphConfig`. Defaults to that class's own defaults. """ self._client: AssetsApiClient = client + self._config: ComponentGraphConfig = ( + config if config is not None else ComponentGraphConfig() + ) async def get_component_graph( self, microgrid_id: MicrogridId @@ -100,7 +106,7 @@ async def get_component_graph( graph = ComponentGraph[ ElectricalComponent, ComponentConnection, ElectricalComponentId - ](components, connections) + ](components, connections, self._config) return graph diff --git a/src/frequenz/gridpool/cli/__main__.py b/src/frequenz/gridpool/cli/__main__.py index dee11c9..159cc67 100644 --- a/src/frequenz/gridpool/cli/__main__.py +++ b/src/frequenz/gridpool/cli/__main__.py @@ -11,7 +11,12 @@ from frequenz.client.assets import AssetsApiClient from frequenz.client.common.microgrid import MicrogridId -from frequenz.gridpool import ComponentGraphGenerator, MicrogridConfig, load_configs +from frequenz.gridpool import ( + ComponentGraphConfig, + ComponentGraphGenerator, + MicrogridConfig, + load_configs, +) from frequenz.gridpool.cli._dump_config import dump_map from frequenz.gridpool.cli._patch_config import patch_file from frequenz.gridpool.cli._render_graph import ComponentGraphRenderer, RenderOptions @@ -22,6 +27,17 @@ async def cli() -> None: """CLI tool for gridpool functionality.""" +def _graph_config(prefer_meters: bool) -> ComponentGraphConfig | None: + """Build the graph config for `--prefer-meters-in-component-formulas`. + + Returns `None` when the flag is not set, so the component graph library's + own defaults apply. + """ + if not prefer_meters: + return None + return ComponentGraphConfig(prefer_meters_in_component_formulas=True) + + @cli.command() @click.argument("microgrid_id", type=int) @click.option( @@ -30,9 +46,17 @@ async def cli() -> None: default="{component}", help="Prefix format for the output (Supports {microgrid_id} and {component} placeholders).", ) +@click.option( + "--prefer-meters-in-component-formulas", + is_flag=True, + default=False, + help="Read the meter before the component in the per-category formulas. " + "This is the order used before component graph v0.5.0.", +) async def print_formulas( microgrid_id: int, prefix: str, + prefer_meters_in_component_formulas: bool, ) -> None: """Fetch and print component graph formulas for a microgrid.""" url = os.environ.get("ASSETS_API_URL") @@ -48,7 +72,9 @@ async def print_formulas( auth_key=key, sign_secret=secret, ) as client: - cgg = ComponentGraphGenerator(client) + cgg = ComponentGraphGenerator( + client, config=_graph_config(prefer_meters_in_component_formulas) + ) graph = await cgg.get_component_graph(MicrogridId(microgrid_id)) power_formulas = { @@ -133,11 +159,19 @@ async def render_graph(microgrid_id: int, output: str, show: bool) -> None: "existing comments, ordering and formatting in that file; only fills in " "values it is missing. Requires --default.", ) +@click.option( + "--prefer-meters-in-component-formulas", + is_flag=True, + default=False, + help="Read the meter before the component in the per-category formulas. " + "This is the order used before component graph v0.5.0.", +) async def generate_config( microgrid_ids: tuple[int, ...], default_file: Path | None, override_file: Path | None, inplace: bool, + prefer_meters_in_component_formulas: bool, ) -> None: """Generate microgrid config from the Assets API as TOML. @@ -149,6 +183,10 @@ async def generate_config( IDs are given, they are taken from the supplied files. Files are only read; redirect stdout to save the result. + With `--prefer-meters-in-component-formulas`, the per-category formulas + read the meter before the component, which is the order used before + component graph v0.5.0. + With `--inplace`, `--default` is patched directly instead: candidate values come from the Assets API (with `--override` layered on top), and only leaves `--default` is missing are added, preserving its existing comments, @@ -178,6 +216,9 @@ async def generate_config( assets_client=client, override_files=override_file, microgrid_ids=ids, + component_graph_config=_graph_config( + prefer_meters_in_component_formulas + ), ) else: configs = await load_configs( @@ -185,6 +226,9 @@ async def generate_config( assets_client=client, override_files=override_file, microgrid_ids=ids, + component_graph_config=_graph_config( + prefer_meters_in_component_formulas + ), ) if not configs: diff --git a/src/frequenz/gridpool/config/__init__.py b/src/frequenz/gridpool/config/__init__.py index edbf760..7be40f1 100644 --- a/src/frequenz/gridpool/config/__init__.py +++ b/src/frequenz/gridpool/config/__init__.py @@ -3,6 +3,8 @@ """Microgrid configuration data model and loading.""" +from frequenz.microgrid_component_graph import ComponentGraphConfig, FormulaOverrides + from .load import ( load_configs, load_configs_from_api, @@ -24,8 +26,10 @@ __all__ = [ "BatteryConfig", "ComponentCategory", + "ComponentGraphConfig", "ComponentType", "ComponentTypeConfig", + "FormulaOverrides", "Metadata", "MicrogridConfig", "PVConfig", diff --git a/src/frequenz/gridpool/config/load.py b/src/frequenz/gridpool/config/load.py index ddacd14..5aaf170 100644 --- a/src/frequenz/gridpool/config/load.py +++ b/src/frequenz/gridpool/config/load.py @@ -8,6 +8,7 @@ from frequenz.client.assets import AssetsApiClient from frequenz.client.common.microgrid import MicrogridId +from frequenz.microgrid_component_graph import ComponentGraphConfig from .._graph_generator import ( ComponentGraphGenerator, @@ -38,6 +39,7 @@ async def load_configs( assets_client: AssetsApiClient | None = None, override_files: str | Path | list[str | Path] | None = None, microgrid_ids: list[int] | None = None, + component_graph_config: ComponentGraphConfig | None = None, ) -> dict[str, "MicrogridConfig"]: """Load configs from up to three sources and merge them in layers. @@ -72,6 +74,10 @@ async def load_configs( Optional explicit microgrid IDs to fetch from the Assets API. When given, these replace the IDs derived from the files, so the Assets API layer can be used without any files. + component_graph_config: + How to build the component graph and generate its formulas. See + `ComponentGraphConfig`. Defaults to that class's own defaults. + Requires an `assets_client`. Returns: dict[str, MicrogridConfig]: @@ -80,7 +86,8 @@ async def load_configs( Raises: ValueError: If none of the three sources is provided, or if - `microgrid_ids` is given without an `assets_client`. + `microgrid_ids` or `component_graph_config` is given without an + `assets_client`. """ if default_files is None and assets_client is None and override_files is None: raise ValueError("At least one config source must be provided.") @@ -88,6 +95,9 @@ async def load_configs( if microgrid_ids is not None and assets_client is None: raise ValueError("microgrid_ids requires an assets_client.") + if component_graph_config is not None and assets_client is None: + raise ValueError("component_graph_config requires an assets_client.") + configs: dict[str, MicrogridConfig] = {} if default_files is not None: configs = load_configs_from_files( @@ -106,6 +116,7 @@ async def load_configs( assets_configs = await load_configs_from_api( assets_client=assets_client, microgrid_ids=microgrid_ids, + component_graph_config=component_graph_config, ) configs = merge_config_maps(base=configs, override=assets_configs) @@ -166,6 +177,7 @@ def load_configs_from_files( async def load_configs_from_api( assets_client: AssetsApiClient, microgrid_ids: list[int], + component_graph_config: ComponentGraphConfig | None = None, ) -> dict[str, "MicrogridConfig"]: """Load microgrid configs from the Assets API. @@ -184,6 +196,9 @@ async def load_configs_from_api( component graph. microgrid_ids: List of microgrid IDs to load configurations for. + component_graph_config: + How to build the component graph and generate its formulas. See + `ComponentGraphConfig`. Defaults to that class's own defaults. Returns: dict[str, MicrogridConfig]: @@ -192,6 +207,7 @@ async def load_configs_from_api( loaded are omitted, so the returned mapping may cover fewer microgrids than were requested. """ + generator = ComponentGraphGenerator(assets_client, config=component_graph_config) configs: dict[str, MicrogridConfig] = {} for microgrid_id in microgrid_ids: try: @@ -205,9 +221,7 @@ async def load_configs_from_api( continue try: - graph = await ComponentGraphGenerator(assets_client).get_component_graph( - MicrogridId(microgrid_id) - ) + graph = await generator.get_component_graph(MicrogridId(microgrid_id)) cfg.ctype = _derive_component_configs(graph) except Exception as exc: # pylint: disable=broad-except _logger.warning( diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4e48557 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,94 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the gridpool CLI.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +from asyncclick.testing import CliRunner +from frequenz.client.assets import AssetsApiClient +from frequenz.client.assets.electrical_component import ( + ComponentConnection, + GridConnectionPoint, + Meter, + SolarInverter, +) +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId + +from frequenz.gridpool.cli.__main__ import _graph_config, cli + +_ENV = { + "ASSETS_API_URL": "grpc://localhost", + "ASSETS_API_AUTH_KEY": "key", + "ASSETS_API_SIGN_SECRET": "secret", +} + + +def _mock_client() -> MagicMock: + """Mock an Assets API client: grid 1 -> meter 2 -> solar inverter 4.""" + client = MagicMock(spec=AssetsApiClient) + client.get_microgrid = AsyncMock(return_value=MagicMock(location=None)) + client.list_microgrid_electrical_components = AsyncMock( + return_value=[ + GridConnectionPoint( + id=ElectricalComponentId(1), + microgrid_id=MicrogridId(10), + rated_fuse_current=100, + ), + Meter(id=ElectricalComponentId(2), microgrid_id=MicrogridId(10)), + SolarInverter(id=ElectricalComponentId(4), microgrid_id=MicrogridId(10)), + ] + ) + client.list_microgrid_electrical_component_connections = AsyncMock( + return_value=[ + ComponentConnection( + source=ElectricalComponentId(1), destination=ElectricalComponentId(2) + ), + ComponentConnection( + source=ElectricalComponentId(2), destination=ElectricalComponentId(4) + ), + ] + ) + return client + + +def _patched_client() -> MagicMock: + """Patch `AssetsApiClient` so the CLI's `async with` yields the mock.""" + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=_mock_client()) + ctx.__aexit__ = AsyncMock(return_value=False) + return MagicMock(return_value=ctx) + + +async def test_print_formulas_prefer_meters_flips_the_order() -> None: + """The flag makes the CLI print the meter first.""" + with patch("frequenz.gridpool.cli.__main__.AssetsApiClient", _patched_client()): + result = await CliRunner().invoke( + cli, + ["print-formulas", "10", "--prefer-meters-in-component-formulas"], + env=_ENV, + ) + + assert result.exit_code == 0, result.output + assert 'pv = "COALESCE(#2, #4, 0.0)"' in result.output + + +async def test_generate_config_prefer_meters_flips_the_order() -> None: + """The flag reaches the formulas written into the config.""" + with patch("frequenz.gridpool.cli.__main__.AssetsApiClient", _patched_client()): + result = await CliRunner().invoke( + cli, + ["generate-config", "10", "--prefer-meters-in-component-formulas"], + env=_ENV, + ) + + assert result.exit_code == 0, result.output + assert ( + '10.ctype.pv.formula.AC_POWER_ACTIVE = "COALESCE(#2, #4, 0.0)"' in result.output + ) + + +def test_graph_config_is_none_without_the_flag() -> None: + """With no flag no config is built, so the library's defaults apply.""" + assert _graph_config(False) is None diff --git a/tests/test_graph_generator.py b/tests/test_graph_generator.py index a5901b3..342e10d 100644 --- a/tests/test_graph_generator.py +++ b/tests/test_graph_generator.py @@ -15,11 +15,12 @@ from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId +from frequenz.gridpool import ComponentGraphConfig from frequenz.gridpool._graph_generator import ComponentGraphGenerator -async def test_formula_generation() -> None: - """Test formula generation from component graph created from Assets API.""" +def _mock_client() -> MagicMock: + """Mock an Assets API client: grid 1 -> meter 2 -> solar inverter 4, + meter 3.""" assets_client_mock = MagicMock(spec=AssetsApiClient) assets_client_mock.list_microgrid_electrical_components = AsyncMock( return_value=[ @@ -59,8 +60,25 @@ async def test_formula_generation() -> None: ] ) - g = ComponentGraphGenerator(assets_client_mock) + return assets_client_mock + + +async def test_formula_generation() -> None: + """Test formula generation from component graph created from Assets API.""" + g = ComponentGraphGenerator(_mock_client()) graph = await g.get_component_graph(MicrogridId(10)) assert graph.grid_formula() == "COALESCE(#2, #4, 0.0) + #3" + assert graph.pv_formula(None) == "COALESCE(#4, #2, 0.0)" + + +async def test_formula_generation_with_a_component_graph_config() -> None: + """A component graph config reaches the generated formulas.""" + g = ComponentGraphGenerator( + _mock_client(), + ComponentGraphConfig(prefer_meters_in_component_formulas=True), + ) + graph = await g.get_component_graph(MicrogridId(10)) + + # Meter first, the opposite of the default order. assert graph.pv_formula(None) == "COALESCE(#2, #4, 0.0)" diff --git a/tests/test_load.py b/tests/test_load.py index 59be309..da71aa2 100644 --- a/tests/test_load.py +++ b/tests/test_load.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock +import pytest from frequenz.client.assets import AssetsApiClient from frequenz.client.assets.electrical_component import ( ComponentConnection, @@ -15,12 +16,14 @@ from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId +from frequenz.gridpool import ComponentGraphConfig from frequenz.gridpool._graph_generator import ( ComponentGraphGenerator, MicrogridComponentGraph, ) from frequenz.gridpool.config.load import ( _derive_component_configs, + load_configs, load_configs_from_api, ) @@ -66,7 +69,7 @@ async def test_load_configs_from_api_derives_formulas_and_ids() -> None: configs = await load_configs_from_api(_mock_client(), [10]) cfg = configs["10"] - assert cfg.ctype["pv"].formula == {"AC_POWER_ACTIVE": "COALESCE(#2, #4, 0.0)"} + assert cfg.ctype["pv"].formula == {"AC_POWER_ACTIVE": "COALESCE(#4, #2, 0.0)"} assert cfg.ctype["pv"].inverter == [4] assert cfg.ctype["pv"].meter == [2] assert cfg.ctype["grid"].meter == [2, 3] @@ -74,6 +77,45 @@ async def test_load_configs_from_api_derives_formulas_and_ids() -> None: assert set(cfg.ctype) == {"grid", "consumption", "pv"} +async def test_load_configs_from_api_honours_the_component_graph_config() -> None: + """A component graph config reaches the derived formulas.""" + configs = await load_configs_from_api( + _mock_client(), + [10], + component_graph_config=ComponentGraphConfig( + prefer_meters_in_component_formulas=True + ), + ) + + # Meter first, the opposite of the default order asserted above. + ctype = configs["10"].ctype + assert ctype["pv"].formula == {"AC_POWER_ACTIVE": "COALESCE(#2, #4, 0.0)"} + + +async def test_load_configs_forwards_the_component_graph_config() -> None: + """`load_configs` passes its component graph config down to the API layer.""" + configs = await load_configs( + assets_client=_mock_client(), + microgrid_ids=[10], + component_graph_config=ComponentGraphConfig( + prefer_meters_in_component_formulas=True + ), + ) + + assert configs["10"].ctype["pv"].formula == { + "AC_POWER_ACTIVE": "COALESCE(#2, #4, 0.0)" + } + + +async def test_load_configs_rejects_a_component_graph_config_without_a_client() -> None: + """A component graph config is only meaningful with an Assets API client.""" + with pytest.raises(ValueError, match="requires an assets_client"): + await load_configs( + default_files=[], + component_graph_config=ComponentGraphConfig(), + ) + + async def test_load_configs_from_api_keeps_metadata_when_graph_fails() -> None: """A graph-derivation failure still yields a metadata-only config.""" client = _mock_client() @@ -94,7 +136,7 @@ async def test_derive_component_configs_builds_formulas_and_ids() -> None: configs = _derive_component_configs(graph) - assert configs["pv"].formula == {"AC_POWER_ACTIVE": "COALESCE(#2, #4, 0.0)"} + assert configs["pv"].formula == {"AC_POWER_ACTIVE": "COALESCE(#4, #2, 0.0)"} assert configs["pv"].inverter == [4] assert configs["pv"].meter == [2] assert configs["grid"].meter == [2, 3]