diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 09896ab..698e411 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,7 +2,7 @@ ## Summary - +This release adds a new Assets API based configuration loader, introduces helpers to merge microgrid configs, and updates PV curtailability behavior to support unspecified values. ## Upgrading @@ -10,7 +10,9 @@ ## New Features -* Add start and end time to microgrid configs. +* Added `MicrogridConfig.load_configs_from_assets_api(...)` to load microgrid metadata (latitude/longitude) from the Assets API and optionally populate formulas from the component graph. +* 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. ## Bug Fixes diff --git a/src/frequenz/gridpool/__init__.py b/src/frequenz/gridpool/__init__.py index 78c4629..c539309 100644 --- a/src/frequenz/gridpool/__init__.py +++ b/src/frequenz/gridpool/__init__.py @@ -4,6 +4,17 @@ """High-level interface to grid pools for the Frequenz platform.""" from ._graph_generator import ComponentGraphGenerator -from ._microgrid_config import Metadata, MicrogridConfig +from ._microgrid_config import ( + Metadata, + MicrogridConfig, + merge_config_maps, + merge_microgrid_configs, +) -__all__ = ["ComponentGraphGenerator", "Metadata", "MicrogridConfig"] +__all__ = [ + "ComponentGraphGenerator", + "Metadata", + "MicrogridConfig", + "merge_config_maps", + "merge_microgrid_configs", +] diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/_microgrid_config.py index f83fe8f..b372d69 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/_microgrid_config.py @@ -6,6 +6,7 @@ import logging import re import tomllib +from copy import deepcopy from dataclasses import field from datetime import datetime from pathlib import Path @@ -420,6 +421,77 @@ def load_configs( return microgrid_configs + @staticmethod + async def load_configs_from_assets_api( + assets_url: str, + assets_auth_key: str, + assets_sign_secret: str, + microgrid_ids: list[int], + populate_formulas: bool = True, + ) -> dict[str, "MicrogridConfig"]: + """Load microgrid configs with location metadata 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 + (e.g. the forecast pipeline) do not have to re-implement this logic. + + Args: + assets_url: + Base URL of the Assets API. + assets_auth_key: + Authentication key used to access the Assets API. + assets_sign_secret: + Signing secret used for authenticated API requests. + 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. + """ + async with AssetsApiClient( + assets_url, + auth_key=assets_auth_key, + sign_secret=assets_sign_secret, + ) as client: + configs: dict[str, MicrogridConfig] = {} + for microgrid_id in microgrid_ids: + try: + mgrid = await client.get_microgrid(MicrogridId(microgrid_id)) + location = mgrid.location if mgrid.location else None + cfg = MicrogridConfig( + meta=Metadata( + microgrid_id=microgrid_id, + latitude=location.latitude if location else None, + longitude=location.longitude if location else None, + ) + ) + if populate_formulas: + await populate_missing_formulas( + microgrid_id=microgrid_id, + config=cfg, + assets_client=client, + ) + except Exception as exc: # pylint: disable=broad-except + _logger.warning( + "Failed to load microgrid %s from the Assets API: %s", + microgrid_id, + exc, + ) + continue + configs[str(microgrid_id)] = cfg + + return configs + @staticmethod async def load_configs_with_formulas( assets_url: str, @@ -451,10 +523,10 @@ async def load_configs_with_formulas( Returns: dict[str, MicrogridConfig]: Mapping from microgrid ID (as string) to the corresponding populated - ``MicrogridConfig`` instance. + `MicrogridConfig` instance. Notes: - - Configuration files are first loaded via ``MicrogridConfig.load_configs``. + - Configuration files are first loaded via `MicrogridConfig.load_configs`. - Any missing formulas are populated by querying the Assets API and generating formulas from the microgrid component graph. """ @@ -479,6 +551,84 @@ async def load_configs_with_formulas( return microgrid_configs +def merge_microgrid_configs( + base: MicrogridConfig, + override: MicrogridConfig, +) -> MicrogridConfig: + """Merge two `MicrogridConfig` objects. + + The *override* config takes precedence over *base*. Nested dictionaries + are merged recursively. If a field in *override* is `None` the value + from *base* is retained, so partial overrides never nullify existing data. + + Args: + base: The base MicrogridConfig. + override: The overriding MicrogridConfig. + + Returns: + A new MicrogridConfig representing the merged result. + """ + schema = MicrogridConfig.Schema() + base_dict = schema.dump(base) + override_dict = schema.dump(override) + + def _deep_merge(a: dict[Any, Any], b: dict[Any, Any]) -> dict[Any, Any]: + result = deepcopy(a) + for k, v in b.items(): + if v is None: + continue + if isinstance(v, dict) and isinstance(result.get(k), dict): + result[k] = _deep_merge(result[k], v) + else: + result[k] = v + return result + + merged = schema.load(_deep_merge(base_dict, override_dict)) + assert isinstance(merged, MicrogridConfig) + return merged + + +def merge_config_maps( + base: dict[str, MicrogridConfig], + override: dict[str, MicrogridConfig], +) -> dict[str, MicrogridConfig]: + """Merge two dictionaries of `MicrogridConfig` objects. + + For microgrid IDs present in both maps the configs are merged via + `merge_microgrid_configs`. IDs that exist only in one map are + included unchanged. + + Args: + base: The base dictionary of MicrogridConfig objects. + override: The overriding dictionary of MicrogridConfig objects. + + Returns: + A new dictionary representing the merged result. + """ + merged = dict(base) + for mid, cfg in override.items(): + if mid in merged: + merged[mid] = merge_microgrid_configs(merged[mid], cfg) + else: + merged[mid] = cfg + return merged + + +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", @@ -488,9 +638,9 @@ async def populate_missing_formulas( """Populate missing component formulas from the assets API graph. Builds a component graph for the given microgrid and derives default formulas - for common component types such as consumption, generation, 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 + 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. Args: @@ -502,23 +652,24 @@ async def populate_missing_formulas( assets_client: Assets API client used to fetch the component graph. component_types: - Optional set of component types to consider when populating formulas. + Set of component types to consider when populating formulas. When + `None` (the default), every component type a formula can be derived + for is considered. 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 same derived formula is assigned to all supported metric keys for a - given component type when missing. + - 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. """ cgg = ComponentGraphGenerator(assets_client) graph = await cgg.get_component_graph(MicrogridId(microgrid_id)) auto_formulas = { "consumption": graph.consumer_formula(), - "generation": graph.producer_formula(), "grid": graph.grid_formula(), "pv": graph.pv_formula(None), "battery": graph.battery_formula(None), @@ -526,28 +677,22 @@ async def populate_missing_formulas( "ev": graph.ev_charger_formula(None), } - metrics = ( - "AC_POWER_ACTIVE", - "AC_ACTIVE_POWER", - "AC_ENERGY_ACTIVE", - "AC_ENERGY_ACTIVE_CONSUMED", - "AC_ENERGY_ACTIVE_DELIVERED", - ) - - # Default: only populate component types already present in the config. - allowed_ctypes = component_types or set(config.ctype.keys()) - for ctype, formula in auto_formulas.items(): - if ctype not in allowed_ctypes: + 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: - continue + cfg = ComponentTypeConfig() + config.ctype[ctype] = cfg if cfg.formula is None: cfg.formula = {} - for metric in metrics: - if metric not in cfg.formula: - cfg.formula[metric] = formula + if "AC_POWER_ACTIVE" not in cfg.formula: + cfg.formula["AC_POWER_ACTIVE"] = formula diff --git a/tests/test_merge_config.py b/tests/test_merge_config.py new file mode 100644 index 0000000..5971c26 --- /dev/null +++ b/tests/test_merge_config.py @@ -0,0 +1,123 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the config merge helpers.""" + +from typing import Any + +from frequenz.gridpool import ( + MicrogridConfig, + merge_config_maps, + merge_microgrid_configs, +) + + +def _make_config(mid: int, entry: dict[str, Any]) -> MicrogridConfig: + """Build a single MicrogridConfig from a table entry. + + Args: + mid: The microgrid ID; also used as the table key. + entry: The microgrid config body (meta, ctype, ...). + + Returns: + The parsed MicrogridConfig instance. + """ + meta = {"microgrid_id": mid, **entry.get("meta", {})} + # pylint: disable=protected-access + return MicrogridConfig._load_table_entries({str(mid): {**entry, "meta": meta}})[ + str(mid) + ] + + +def test_merge_override_wins() -> None: + """Override scalar fields take precedence over the base.""" + base = _make_config(1, {"meta": {"name": "Base Grid", "latitude": 10.0}}) + override = _make_config(1, {"meta": {"name": "New Grid", "latitude": 99.0}}) + + merged = merge_microgrid_configs(base, override) + + assert merged.meta is not None + assert merged.meta.name == "New Grid" + assert merged.meta.latitude == 99.0 + + +def test_merge_none_does_not_nullify() -> None: + """Fields absent from the override retain their base values.""" + base = _make_config( + 1, + {"meta": {"name": "Base Grid", "enterprise_id": 5, "longitude": 20.0}}, + ) + # Override only sets the name; enterprise_id/longitude are None and skipped. + override = _make_config(1, {"meta": {"name": "New Grid"}}) + + merged = merge_microgrid_configs(base, override) + + assert merged.meta is not None + assert merged.meta.name == "New Grid" + assert merged.meta.enterprise_id == 5 + assert merged.meta.longitude == 20.0 + + +def test_merge_nested_ctype() -> None: + """Nested dicts are merged recursively, keeping base-only keys.""" + base = _make_config( + 1, + { + "ctype": { + "pv": {"meter": [1, 2]}, + "battery": {"inverter": [3, 4]}, + } + }, + ) + override = _make_config( + 1, + { + "ctype": { + "pv": {"formula": {"AC_POWER_ACTIVE": "#1"}}, + "grid": {"meter": [9]}, + } + }, + ) + + merged = merge_microgrid_configs(base, override) + + # pv: base meter retained, override formula added. + assert merged.ctype["pv"].meter == [1, 2] + assert merged.ctype["pv"].formula == {"AC_POWER_ACTIVE": "#1"} + # battery: present only in base, kept unchanged. + assert merged.ctype["battery"].inverter == [3, 4] + # grid: present only in override, added. + assert merged.ctype["grid"].meter == [9] + + +def test_merge_does_not_mutate_inputs() -> None: + """Merging returns a new config without mutating base or override.""" + base = _make_config(1, {"meta": {"name": "Base Grid"}}) + override = _make_config(1, {"meta": {"name": "New Grid"}}) + + merge_microgrid_configs(base, override) + + assert base.meta is not None and base.meta.name == "Base Grid" + assert override.meta is not None and override.meta.name == "New Grid" + + +def test_merge_config_maps() -> None: + """Maps are merged per ID; IDs unique to one map are passed through.""" + base = { + "1": _make_config(1, {"meta": {"name": "Base One"}}), + "2": _make_config(2, {"meta": {"name": "Base Two"}}), + } + override = { + "1": _make_config(1, {"meta": {"name": "Override One"}}), + "3": _make_config(3, {"meta": {"name": "Override Three"}}), + } + + merged = merge_config_maps(base, override) + + assert set(merged) == {"1", "2", "3"} + # Shared ID: override wins. + assert merged["1"].meta is not None and merged["1"].meta.name == "Override One" + # Base-only ID: unchanged. + assert merged["2"].meta is not None and merged["2"].meta.name == "Base Two" + # Override-only ID: included. + assert merged["3"].meta is not None and merged["3"].meta.name == "Override Three"