diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 03223e2..8ec38ba 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,7 +2,7 @@ ## Summary -This release reworks the microgrid configuration loading API: the loaders are now module-level functions exported from `frequenz.gridpool` for loading configs from files, from the Assets API, or from both layered together. +This release reworks the microgrid configuration loading API: the loaders are now module-level functions exported from `frequenz.gridpool` for loading configs from files, from the Assets API, or from both layered together. It also adds a `generate-config` CLI command. ## Upgrading @@ -10,15 +10,17 @@ The configuration loaders are no longer `MicrogridConfig` static methods — the * `MicrogridConfig.load_configs(files, dir)` → `load_configs_from_files(files)`. The `microgrid_config_dir` argument has been removed; pass an explicit list of files instead, e.g. `load_configs_from_files(list(Path(my_dir).glob("*.toml")))`. Note that the name `load_configs` now refers to the new merge wrapper (see New Features), not the file loader. * `MicrogridConfig.load_configs_with_formulas(assets_url, assets_auth_key, assets_sign_secret, files, dir)` → `load_configs(default_files=files, assets_client=client, override_files=...)`. It now takes an `AssetsApiClient` you construct instead of raw credentials, and layers its sources by explicit precedence (default files < Assets API < override files) rather than the old "load files, then fill in missing formulas" behavior. +* The CLI dependencies (`asyncclick` and the new `tomlkit`) are no longer core dependencies; they have moved to a `cli` optional extra. Install the CLI with `pip install frequenz-gridpool[cli]` (the `render-graph` extra now pulls this in automatically). ## New Features * Added config loading functions, exported from `frequenz.gridpool`: * `load_configs_from_files(...)` loads microgrid configs from one or more TOML files. - * `load_configs_from_api(...)` loads microgrid metadata (latitude/longitude) from the Assets API and optionally populates formulas from the component graph. Formulas are derived for all supported component types by default, and a microgrid that cannot be loaded is logged and skipped rather than aborting the whole batch. + * `load_configs_from_api(...)` loads microgrid metadata (latitude/longitude) from the Assets API and derives the per-type formulas and meter/inverter/component IDs directly from the component graph. The two steps fail independently: a microgrid whose metadata cannot be fetched is skipped, while one whose component graph cannot be derived is still returned with metadata only. Both are logged rather than aborting the whole batch. * `load_configs(...)` loads from up to three layered sources — default files, the Assets API, and override files — and merges them, with higher layers winning on conflicts. The microgrid IDs fetched from the Assets API can be given explicitly or are otherwise taken from the files. * Added `merge_microgrid_configs(...)` for deep-merging two `MicrogridConfig` objects where override values take precedence and `None` does not overwrite base values. * Added `merge_config_maps(...)` for merging two dictionaries of microgrid configs by microgrid ID. +* Added a `generate-config` CLI command that derives a microgrid config from the Assets API and prints it as TOML, optionally layering `--default` and `--override` config files by precedence (`--default` < Assets API < `--override`). ## Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index a248a91..facc3f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ requires = [ "setuptools == 82.0.1", "setuptools_scm[toml] == 10.0.5", + "vcs_versioning < 2", # setuptools_scm 10.0.5 needs parse_version, dropped in 2.x "frequenz-repo-config[lib] == 0.17.0", ] build-backend = "setuptools.build_meta" @@ -27,7 +28,6 @@ classifiers = [ requires-python = ">= 3.11, < 4" dependencies = [ "marshmallow-dataclass >= 8.7.1, < 9", - "asyncclick >= 8.3.0.4, < 9", "typing-extensions >= 4.14.1, < 5", "frequenz-microgrid-component-graph >= 0.4.0, < 0.5", "frequenz-client-assets >= 0.3.1, < 0.4", @@ -42,7 +42,12 @@ name = "Frequenz Energy-as-a-Service GmbH" email = "floss@frequenz.com" [project.optional-dependencies] +cli = [ + "asyncclick >= 8.3.0.4, < 9", + "tomlkit >= 0.13.0, < 1", +] render-graph = [ + "frequenz-gridpool[cli]", "matplotlib >= 3.7.0, < 4", "networkx >= 3.0, < 4", ] @@ -91,6 +96,7 @@ 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 ] 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/_graph_generator.py b/src/frequenz/gridpool/_graph_generator.py index 185fea2..0430960 100644 --- a/src/frequenz/gridpool/_graph_generator.py +++ b/src/frequenz/gridpool/_graph_generator.py @@ -1,15 +1,28 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Formula generation from assets API component/connection configurations.""" +"""Component graph generation and per-type queries over the Assets API. + +The `ComponentGraphGenerator` builds a `MicrogridComponentGraph` from the +Platform Assets API. The module-level query functions (e.g. `pv_meter_ids`) +return component IDs read back out of such a graph. +""" import logging +from collections.abc import Callable from frequenz.client.assets import AssetsApiClient from frequenz.client.assets.electrical_component import ( + Battery, + BatteryInverter, Breaker, + Chp, ComponentConnection, ElectricalComponent, + EvCharger, + GridConnectionPoint, + Meter, + SolarInverter, ) from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId @@ -17,6 +30,11 @@ _logger = logging.getLogger(__name__) +MicrogridComponentGraph = ComponentGraph[ + ElectricalComponent, ComponentConnection, ElectricalComponentId +] +"""Component graph specialized for a microgrid's electrical components.""" + class ComponentGraphGenerator: """Generates component graphs for microgrids using the Assets API.""" @@ -35,9 +53,7 @@ def __init__( async def get_component_graph( self, microgrid_id: MicrogridId - ) -> ComponentGraph[ - ElectricalComponent, ComponentConnection, ElectricalComponentId - ]: + ) -> MicrogridComponentGraph: """Generate a component graph for the given microgrid ID. Args: @@ -87,3 +103,79 @@ async def get_component_graph( ](components, connections) return graph + + +def _ids_of( + graph: MicrogridComponentGraph, component_class: type[ElectricalComponent] +) -> list[int]: + """Return the sorted IDs of all components of the given class.""" + return sorted(int(c.id) for c in graph.components(matching_types=component_class)) + + +def _meter_ids_where( + graph: MicrogridComponentGraph, + is_meter: Callable[[ElectricalComponentId], bool], +) -> list[int]: + """Return the sorted IDs of all meters matching the given classifier.""" + return sorted( + int(m.id) for m in graph.components(matching_types=Meter) if is_meter(m.id) + ) + + +def grid_meter_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the meters directly downstream of a grid connection point. + + Grid meters have no dedicated classifier, so they are derived from the graph + topology. + """ + grid_ids = {int(c.id) for c in graph.components(matching_types=GridConnectionPoint)} + return sorted( + int(m.id) + for m in graph.components(matching_types=Meter) + if any(int(p.id) in grid_ids for p in graph.predecessors(m.id)) + ) + + +def pv_meter_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all PV meters.""" + return _meter_ids_where(graph, graph.is_pv_meter) + + +def pv_inverter_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all solar inverters.""" + return _ids_of(graph, SolarInverter) + + +def battery_meter_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all battery meters.""" + return _meter_ids_where(graph, graph.is_battery_meter) + + +def battery_inverter_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all battery inverters.""" + return _ids_of(graph, BatteryInverter) + + +def battery_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all batteries.""" + return _ids_of(graph, Battery) + + +def chp_meter_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all CHP meters.""" + return _meter_ids_where(graph, graph.is_chp_meter) + + +def chp_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all CHPs.""" + return _ids_of(graph, Chp) + + +def ev_charger_meter_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all EV charger meters.""" + return _meter_ids_where(graph, graph.is_ev_charger_meter) + + +def ev_charger_ids(graph: MicrogridComponentGraph) -> list[int]: + """Return the sorted IDs of all EV chargers.""" + return _ids_of(graph, EvCharger) diff --git a/src/frequenz/gridpool/cli/__main__.py b/src/frequenz/gridpool/cli/__main__.py index 0445e88..a631dee 100644 --- a/src/frequenz/gridpool/cli/__main__.py +++ b/src/frequenz/gridpool/cli/__main__.py @@ -4,12 +4,14 @@ """CLI tool for gridpool functionality.""" import os +from pathlib import Path import asyncclick as click from frequenz.client.assets import AssetsApiClient from frequenz.client.common.microgrid import MicrogridId -from frequenz.gridpool import ComponentGraphGenerator +from frequenz.gridpool import ComponentGraphGenerator, load_configs +from frequenz.gridpool.cli._dump_config import dump_map from frequenz.gridpool.cli._render_graph import ComponentGraphRenderer, RenderOptions @@ -105,6 +107,61 @@ async def render_graph(microgrid_id: int, output: str, show: bool) -> None: raise click.ClickException(str(exc)) from exc +@cli.command("generate-config") +@click.argument("microgrid_ids", type=int, nargs=-1) +@click.option( + "--default", + "default_file", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + default=None, + help="Config file whose values the Assets API overrides (lowest precedence).", +) +@click.option( + "--override", + "override_file", + type=click.Path(exists=True, dir_okay=False, path_type=Path), + default=None, + help="Config file whose values override the Assets API (highest precedence).", +) +async def generate_config( + microgrid_ids: tuple[int, ...], + default_file: Path | None, + override_file: Path | None, +) -> None: + """Generate microgrid config from the Assets API as TOML. + + Derives metadata, formulas and component IDs for the given microgrid IDs and + prints the result as dotted-key TOML to stdout. + + `--default` and `--override` each take a config file and are layered with the + Assets API by precedence: `--default` < Assets API < `--override`. So a file + passed as `--override` keeps its values where it has them (the API only fills + gaps), while a file passed as `--default` is overridden by the API. If no + microgrid IDs are given, they are taken from the supplied files. Files are + only read; redirect stdout to save the result. + """ + url = os.environ.get("ASSETS_API_URL") + key = os.environ.get("ASSETS_API_AUTH_KEY") + secret = os.environ.get("ASSETS_API_SIGN_SECRET") + if not url or not key or not secret: + raise click.ClickException( + "ASSETS_API_URL, ASSETS_API_AUTH_KEY, ASSETS_API_SIGN_SECRET must be set." + ) + + async with AssetsApiClient(url, auth_key=key, sign_secret=secret) as client: + configs = await load_configs( + default_files=default_file, + assets_client=client, + override_files=override_file, + microgrid_ids=list(dict.fromkeys(microgrid_ids)) or None, + ) + + if not configs: + raise click.ClickException("No microgrids could be loaded; nothing to write.") + + click.echo(dump_map(configs), nl=False) + + def main() -> None: """Run the CLI tool.""" cli(_anyio_backend="asyncio") diff --git a/src/frequenz/gridpool/cli/_dump_config.py b/src/frequenz/gridpool/cli/_dump_config.py new file mode 100644 index 0000000..4c56cb4 --- /dev/null +++ b/src/frequenz/gridpool/cli/_dump_config.py @@ -0,0 +1,77 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Serialize microgrid configurations to dotted-key TOML. + +Renders a `{microgrid_id: MicrogridConfig}` mapping as dotted-key TOML, e.g.: + + 115.meta.microgrid_id = 115 + 115.meta.name = "Demo Grid" + 115.meta.latitude = 52.52 + +This is the inverse of `load_configs_from_files`. Value rendering (quoting, +escaping, key-quoting, list/number/datetime formatting) is delegated to +`tomlkit`; only the flattening to dotted keys and the dropping of empty/`None` +fields are done here, since TOML has no null and `tomlkit` rejects it. +""" + +from typing import Any + +import tomlkit + +from frequenz.gridpool import MicrogridConfig + + +def _is_empty(value: Any) -> bool: + """Whether a dumped value should be omitted from the output.""" + return value is None or value == {} or value == [] + + +def _iter_leaves( + prefix: list[str], data: dict[str, Any] +) -> list[tuple[list[str], Any]]: + """Flatten a nested dict into `(key_path, value)` pairs, dropping empties. + + Args: + prefix: The key-path prefix accumulated so far. + data: The nested mapping to flatten. + + Returns: + A list of `(key_path, leaf_value)` pairs, where `key_path` is the list + of nested keys leading to a non-empty scalar or list value. + """ + leaves: list[tuple[list[str], Any]] = [] + for key, value in data.items(): + if _is_empty(value): + continue + path = prefix + [key] + if isinstance(value, dict): + leaves.extend(_iter_leaves(path, value)) + else: + leaves.append((path, value)) + return leaves + + +def dump_map(configs: dict[str, MicrogridConfig]) -> str: + """Serialize a mapping of microgrid configs to dotted-key TOML. + + Args: + configs: Mapping from microgrid ID (as string) to `MicrogridConfig`. + + Returns: + The TOML representation as a string, with one blank line between + microgrids and entries sorted by numeric microgrid ID. + """ + schema = MicrogridConfig.Schema() + doc = tomlkit.document() + for mid in sorted(configs, key=int): + dumped = schema.dump(configs[mid]) + assert isinstance(dumped, dict) + leaves = _iter_leaves([mid], dumped) + if not leaves: + continue + if doc.body: + doc.add(tomlkit.nl()) + for path, value in leaves: + doc.append(tomlkit.key(path), value) + return tomlkit.dumps(doc) diff --git a/src/frequenz/gridpool/config/load.py b/src/frequenz/gridpool/config/load.py index f97c705..ddacd14 100644 --- a/src/frequenz/gridpool/config/load.py +++ b/src/frequenz/gridpool/config/load.py @@ -9,7 +9,20 @@ from frequenz.client.assets import AssetsApiClient from frequenz.client.common.microgrid import MicrogridId -from .._graph_generator import ComponentGraphGenerator +from .._graph_generator import ( + ComponentGraphGenerator, + MicrogridComponentGraph, + battery_ids, + battery_inverter_ids, + battery_meter_ids, + chp_ids, + chp_meter_ids, + ev_charger_ids, + ev_charger_meter_ids, + grid_meter_ids, + pv_inverter_ids, + pv_meter_ids, +) from .microgrid import ( ComponentTypeConfig, Metadata, @@ -153,51 +166,57 @@ def load_configs_from_files( async def load_configs_from_api( assets_client: AssetsApiClient, microgrid_ids: list[int], - populate_formulas: bool = True, ) -> dict[str, "MicrogridConfig"]: - """Load microgrid configs with location metadata from the Assets API. + """Load microgrid configs from the Assets API. - Fetches each microgrid's location (latitude, longitude) and optionally - populates formulas from the component graph. This is the canonical - single-source loader for both metadata and formulas so that callers + For each microgrid, fetches its location metadata (latitude, longitude) and + then derives the per-type formulas and meter/inverter/component IDs from its + component graph. This is the canonical single-source loader so that callers (e.g. the forecast pipeline) do not have to re-implement this logic. + The two steps fail independently: a microgrid whose metadata cannot be + fetched is skipped, while one whose component graph cannot be derived is + still returned with metadata only. Both failures are logged as warnings. + Args: assets_client: Assets API client used to fetch microgrid metadata and the component graph. microgrid_ids: List of microgrid IDs to load configurations for. - populate_formulas: - When `True` (default), formulas are derived from the component - graph and written into each config via - `_populate_missing_formulas`. Set to `False` to load - metadata only. Returns: dict[str, MicrogridConfig]: - Mapping from microgrid ID (as string) to the populated - `MicrogridConfig` instance. Microgrids that could not be loaded - are logged as warnings and omitted, so the returned mapping may - cover fewer microgrids than were requested. + Mapping from microgrid ID (as string) to the loaded + `MicrogridConfig` instance. Microgrids whose metadata could not be + loaded are omitted, so the returned mapping may cover fewer + microgrids than were requested. """ configs: dict[str, MicrogridConfig] = {} for microgrid_id in microgrid_ids: try: cfg = await _build_config_from_metadata(assets_client, microgrid_id) - if populate_formulas: - await _populate_missing_formulas( - microgrid_id=microgrid_id, - config=cfg, - assets_client=assets_client, - ) except Exception as exc: # pylint: disable=broad-except _logger.warning( - "Failed to load microgrid %s from the Assets API: %s", + "Failed to load microgrid %s metadata from the Assets API: %s", microgrid_id, exc, ) continue + + try: + graph = await ComponentGraphGenerator(assets_client).get_component_graph( + MicrogridId(microgrid_id) + ) + cfg.ctype = _derive_component_configs(graph) + except Exception as exc: # pylint: disable=broad-except + _logger.warning( + "Failed to derive component config for microgrid %s from the " + "graph: %s", + microgrid_id, + exc, + ) + configs[str(microgrid_id)] = cfg return configs @@ -229,85 +248,79 @@ async def _build_config_from_metadata( ) -def _is_zero_formula(formula: str) -> bool: - """Return whether a derived formula is empty or a constant zero. - - Component types absent from a microgrid yield an empty formula or one that - is just a zero constant (e.g. `0.0`), which is not worth storing. - """ - stripped = formula.strip() - if not stripped: - return True - try: - return float(stripped) == 0.0 - except ValueError: - return False - - -async def _populate_missing_formulas( - microgrid_id: int, - config: "MicrogridConfig", - assets_client: AssetsApiClient, - component_types: set[str] | None = None, -) -> None: - """Populate missing component formulas from the assets API graph. +def _derive_component_configs( + graph: MicrogridComponentGraph, +) -> dict[str, ComponentTypeConfig]: + """Build the component-type configs for a microgrid from its graph. - Builds a component graph for the given microgrid and derives default formulas - for common component types such as consumption, grid, PV, battery, CHP, and - EV charging. Existing formulas already present in the configuration are - preserved; only missing component-type entries or missing metric formulas - are filled in. + Derives, per component type, the `AC_POWER_ACTIVE` formula and the + meter/inverter/component ID lists straight from the component graph. A + component type only appears in the result if the graph yields a non-zero + formula or at least one ID for it. Args: - microgrid_id: - Identifier of the microgrid whose component graph should be used to - derive formulas. - config: - Microgrid configuration object to update in place. - assets_client: - Assets API client used to fetch the component graph. - component_types: - Set of component types to consider when populating formulas. When - `None` (the default), every component type a formula can be derived - for is considered. + graph: + Component graph to derive the configuration from. Returns: - None. The configuration is modified in place. - - Notes: - - Existing formulas in `config` are never overwritten. - - For missing component types, a new `ComponentTypeConfig` is created. - - The derived formula is assigned to the `AC_POWER_ACTIVE` metric key - for a given component type when missing. + Mapping from component type to its derived `ComponentTypeConfig`. """ - cgg = ComponentGraphGenerator(assets_client) - graph = await cgg.get_component_graph(MicrogridId(microgrid_id)) - - auto_formulas = { - "consumption": graph.consumer_formula(), - "grid": graph.grid_formula(), - "pv": graph.pv_formula(None), - "battery": graph.battery_formula(None), - "chp": graph.chp_formula(None), - "ev": graph.ev_charger_formula(None), - } - - for ctype, formula in auto_formulas.items(): - if component_types is not None and ctype not in component_types: - continue - - # Skip component types, whose derived formula - # is empty or evaluates to a constant zero. - if _is_zero_formula(formula): - continue - cfg = config.ctype.get(ctype) - if cfg is None: - cfg = ComponentTypeConfig() - config.ctype[ctype] = cfg + def as_formula(derived: str) -> dict[str, str] | None: + """Wrap a derived formula for storage, or drop it if empty or zero. - if cfg.formula is None: - cfg.formula = {} + Component types absent from the microgrid yield an empty or + constant-zero formula (e.g. `0.0`), which is not worth storing. + """ + stripped = derived.strip() + try: + if not stripped or float(stripped) == 0.0: + return None + except ValueError: + pass + return {"AC_POWER_ACTIVE": derived} + + configs: dict[str, ComponentTypeConfig] = { + "consumption": ComponentTypeConfig( + meter=None, + inverter=None, + component=None, + formula=as_formula(graph.consumer_formula()), + ), + "grid": ComponentTypeConfig( + meter=grid_meter_ids(graph) or None, + inverter=None, + component=None, + formula=as_formula(graph.grid_formula()), + ), + "pv": ComponentTypeConfig( + meter=pv_meter_ids(graph) or None, + inverter=pv_inverter_ids(graph) or None, + component=None, + formula=as_formula(graph.pv_formula(None)), + ), + "battery": ComponentTypeConfig( + meter=battery_meter_ids(graph) or None, + inverter=battery_inverter_ids(graph) or None, + component=battery_ids(graph) or None, + formula=as_formula(graph.battery_formula(None)), + ), + "chp": ComponentTypeConfig( + meter=chp_meter_ids(graph) or None, + inverter=None, + component=chp_ids(graph) or None, + formula=as_formula(graph.chp_formula(None)), + ), + "ev": ComponentTypeConfig( + meter=ev_charger_meter_ids(graph) or None, + inverter=None, + component=ev_charger_ids(graph) or None, + formula=as_formula(graph.ev_charger_formula(None)), + ), + } - if "AC_POWER_ACTIVE" not in cfg.formula: - cfg.formula["AC_POWER_ACTIVE"] = formula + return { + component_type: cfg + for component_type, cfg in configs.items() + if cfg.formula or cfg.meter or cfg.inverter or cfg.component + } diff --git a/tests/test_dump_config.py b/tests/test_dump_config.py new file mode 100644 index 0000000..9e7938b --- /dev/null +++ b/tests/test_dump_config.py @@ -0,0 +1,44 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for serializing microgrid configs to TOML.""" + +import tomllib + +from frequenz.gridpool import MicrogridConfig +from frequenz.gridpool.cli._dump_config import dump_map +from frequenz.gridpool.config.microgrid import ComponentTypeConfig, Metadata + + +def test_dump_map_round_trips() -> None: + """A serialized config parses back to the same dotted-key structure.""" + configs = { + "10": MicrogridConfig( + meta=Metadata(microgrid_id=10, name="Demo", latitude=52.5), + ctype={ + "pv": ComponentTypeConfig(meter=[2], formula={"AC_POWER_ACTIVE": "#2"}), + }, + ) + } + + parsed = tomllib.loads(dump_map(configs)) + + assert parsed["10"]["meta"]["microgrid_id"] == 10 + assert parsed["10"]["meta"]["name"] == "Demo" + assert parsed["10"]["meta"]["latitude"] == 52.5 + assert parsed["10"]["ctype"]["pv"]["meter"] == [2] + assert parsed["10"]["ctype"]["pv"]["formula"]["AC_POWER_ACTIVE"] == "#2" + + +def test_dump_map_omits_empty_and_none() -> None: + """Empty and None fields are dropped from the output.""" + configs = {"7": MicrogridConfig(meta=Metadata(microgrid_id=7))} + + text = dump_map(configs) + + assert text == "7.meta.microgrid_id = 7\n" + + +def test_dump_map_empty() -> None: + """An empty mapping serializes to an empty string.""" + assert dump_map({}) == "" diff --git a/tests/test_load.py b/tests/test_load.py new file mode 100644 index 0000000..59be309 --- /dev/null +++ b/tests/test_load.py @@ -0,0 +1,101 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for loading microgrid configs from the Assets API.""" + +from unittest.mock import AsyncMock, MagicMock + +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._graph_generator import ( + ComponentGraphGenerator, + MicrogridComponentGraph, +) +from frequenz.gridpool.config.load import ( + _derive_component_configs, + load_configs_from_api, +) + + +def _mock_client() -> MagicMock: + """Mock an Assets API client for one microgrid: grid -> meter -> PV, + a meter.""" + 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)), + Meter(id=ElectricalComponentId(3), 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(1), destination=ElectricalComponentId(3) + ), + ComponentConnection( + source=ElectricalComponentId(2), destination=ElectricalComponentId(4) + ), + ] + ) + return client + + +async def _build_graph(client: MagicMock) -> MicrogridComponentGraph: + return await ComponentGraphGenerator(client).get_component_graph(MicrogridId(10)) + + +async def test_load_configs_from_api_derives_formulas_and_ids() -> None: + """A config loaded from the API gets both formulas and component IDs.""" + 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"].inverter == [4] + assert cfg.ctype["pv"].meter == [2] + assert cfg.ctype["grid"].meter == [2, 3] + # Component types absent from the microgrid are not created. + assert set(cfg.ctype) == {"grid", "consumption", "pv"} + + +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() + client.list_microgrid_electrical_components = AsyncMock( + side_effect=RuntimeError("graph unavailable") + ) + + configs = await load_configs_from_api(client, [10]) + + cfg = configs["10"] + assert cfg.meta.microgrid_id == 10 + assert cfg.ctype == {} + + +async def test_derive_component_configs_builds_formulas_and_ids() -> None: + """The builder derives formulas and IDs and omits types with neither.""" + graph = await _build_graph(_mock_client()) + + configs = _derive_component_configs(graph) + + assert configs["pv"].formula == {"AC_POWER_ACTIVE": "COALESCE(#2, #4, 0.0)"} + assert configs["pv"].inverter == [4] + assert configs["pv"].meter == [2] + assert configs["grid"].meter == [2, 3] + assert set(configs) == {"grid", "consumption", "pv"}