From a59409a6e273536bb9d0f5b4855f3db7f4c256aa Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:12:26 +0200 Subject: [PATCH 01/11] refactor(config): inject the Assets API client into the loaders Both load_configs_from_assets_api and load_configs_with_formulas now accept an AssetsApiClient instead of creating their own instance, leaving client construction and lifecycle to the caller. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/_microgrid_config.py | 97 +++++++++------------- 1 file changed, 38 insertions(+), 59 deletions(-) diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/_microgrid_config.py index b372d69..a72ce62 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/_microgrid_config.py @@ -423,9 +423,7 @@ def load_configs( @staticmethod async def load_configs_from_assets_api( - assets_url: str, - assets_auth_key: str, - assets_sign_secret: str, + assets_client: AssetsApiClient, microgrid_ids: list[int], populate_formulas: bool = True, ) -> dict[str, "MicrogridConfig"]: @@ -437,12 +435,9 @@ async def load_configs_from_assets_api( (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. + 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: @@ -458,45 +453,38 @@ async def load_configs_from_assets_api( 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, - ) + configs: dict[str, MicrogridConfig] = {} + for microgrid_id in microgrid_ids: + try: + mgrid = await assets_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, + ) + if populate_formulas: + await populate_missing_formulas( + microgrid_id=microgrid_id, + config=cfg, + assets_client=assets_client, ) - continue - configs[str(microgrid_id)] = cfg + 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, - assets_auth_key: str, - assets_sign_secret: str, + assets_client: AssetsApiClient, microgrid_config_files: str | Path | list[str | Path] | None = None, microgrid_config_dir: str | Path | None = None, ) -> dict[str, "MicrogridConfig"]: @@ -508,12 +496,8 @@ async def load_configs_with_formulas( defined in the configuration. 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. + assets_client: + Assets API client used to fetch the component graph. microgrid_config_files: Optional path or list of paths to individual microgrid configuration files. @@ -535,18 +519,13 @@ async def load_configs_with_formulas( microgrid_config_dir=microgrid_config_dir, ) - async with AssetsApiClient( - assets_url, auth_key=assets_auth_key, sign_secret=assets_sign_secret - ) as assets_client: - for microgrid_id, config in microgrid_configs.items(): - relevant_ctypes = set(config.ctype.keys()) - - await populate_missing_formulas( - microgrid_id=int(microgrid_id), - config=config, - assets_client=assets_client, - component_types=relevant_ctypes, - ) + for microgrid_id, config in microgrid_configs.items(): + await populate_missing_formulas( + microgrid_id=int(microgrid_id), + config=config, + assets_client=assets_client, + component_types=set(config.ctype.keys()), + ) return microgrid_configs From fcee205e0523c99e83e06915e9eda67b07f256c9 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:12:49 +0200 Subject: [PATCH 02/11] refactor(config): extract _build_config_from_metadata helper Move the metadata building into its own helper function similarly to the formula population. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/_microgrid_config.py | 36 ++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/_microgrid_config.py index a72ce62..e19f8df 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/_microgrid_config.py @@ -456,15 +456,7 @@ async def load_configs_from_assets_api( configs: dict[str, MicrogridConfig] = {} for microgrid_id in microgrid_ids: try: - mgrid = await assets_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, - ) - ) + cfg = await _build_config_from_metadata(assets_client, microgrid_id) if populate_formulas: await populate_missing_formulas( microgrid_id=microgrid_id, @@ -593,6 +585,32 @@ def merge_config_maps( return merged +async def _build_config_from_metadata( + assets_client: AssetsApiClient, microgrid_id: int +) -> MicrogridConfig: + """Build a base config for a microgrid from its Assets API metadata. + + Fetches the microgrid's location and returns a `MicrogridConfig` carrying + only metadata; formulas are populated separately. + + Args: + assets_client: Assets API client used to fetch the microgrid. + microgrid_id: ID of the microgrid to fetch. + + Returns: + A `MicrogridConfig` with metadata populated from the Assets API. + """ + mgrid = await assets_client.get_microgrid(MicrogridId(microgrid_id)) + location = mgrid.location if mgrid.location else None + return MicrogridConfig( + meta=Metadata( + microgrid_id=microgrid_id, + latitude=location.latitude if location else None, + longitude=location.longitude if location else None, + ) + ) + + def _is_zero_formula(formula: str) -> bool: """Return whether a derived formula is empty or a constant zero. From 7346f2500b3fdcd0a39e48cdc7fa405c0457edfd Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:43:48 +0200 Subject: [PATCH 03/11] refactor(config): rename load_configs to load_configs_from_files Name the file-only loader by its source, mirroring load_configs_from_assets_api, so the loaders read as a consistent set. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/_microgrid_config.py | 7 ++++--- tests/test_config.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/_microgrid_config.py index e19f8df..9a0f6d5 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/_microgrid_config.py @@ -359,7 +359,7 @@ def load_from_file(cls, config_path: Path) -> dict[str, Self]: return cls._load_table_entries(data) @staticmethod - def load_configs( + def load_configs_from_files( microgrid_config_files: str | Path | list[str | Path] | None = None, microgrid_config_dir: str | Path | None = None, ) -> dict[str, "MicrogridConfig"]: @@ -502,11 +502,12 @@ async def load_configs_with_formulas( `MicrogridConfig` instance. Notes: - - Configuration files are first loaded via `MicrogridConfig.load_configs`. + - Configuration files are first loaded via + `MicrogridConfig.load_configs_from_files`. - Any missing formulas are populated by querying the Assets API and generating formulas from the microgrid component graph. """ - microgrid_configs = MicrogridConfig.load_configs( + microgrid_configs = MicrogridConfig.load_configs_from_files( microgrid_config_files=microgrid_config_files, microgrid_config_dir=microgrid_config_dir, ) diff --git a/tests/test_config.py b/tests/test_config.py index 8cc23dc..1a688ae 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -128,7 +128,7 @@ def test_load_configs(mocker: MockerFixture) -> None: mock_file = mocker.mock_open(read_data=toml_data.encode("utf-8")) mocker.patch("pathlib.Path.open", mock_file) mocker.patch("pathlib.Path.is_file", mocker.Mock(return_value=True)) - configs = MicrogridConfig.load_configs(Path("mock_path.toml")) + configs = MicrogridConfig.load_configs_from_files(Path("mock_path.toml")) assert "1" in configs assert configs["1"].meta is not None From aa6aa040f2a44992e1aef83446428b88d9d7cd36 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:07:00 +0200 Subject: [PATCH 04/11] refactor(config): remove load_configs_with_formulas The old opaque loader will be replaced with a layered merge wrapper shortly. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/_microgrid_config.py | 52 +--------------------- 1 file changed, 2 insertions(+), 50 deletions(-) diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/_microgrid_config.py index 9a0f6d5..9105fe5 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/_microgrid_config.py @@ -474,54 +474,6 @@ async def load_configs_from_assets_api( return configs - @staticmethod - async def load_configs_with_formulas( - assets_client: AssetsApiClient, - microgrid_config_files: str | Path | list[str | Path] | None = None, - microgrid_config_dir: str | Path | None = None, - ) -> dict[str, "MicrogridConfig"]: - """Load microgrid configurations and ensure formulas are populated. - - Loads microgrid configuration files and enriches them with automatically - generated formulas obtained from the Assets API. Missing formulas are filled - using the component graph generator while preserving any formulas already - defined in the configuration. - - Args: - assets_client: - Assets API client used to fetch the component graph. - microgrid_config_files: - Optional path or list of paths to individual microgrid configuration - files. - microgrid_config_dir: - Optional directory containing microgrid configuration files. - - Returns: - dict[str, MicrogridConfig]: - Mapping from microgrid ID (as string) to the corresponding populated - `MicrogridConfig` instance. - - Notes: - - Configuration files are first loaded via - `MicrogridConfig.load_configs_from_files`. - - Any missing formulas are populated by querying the Assets API and - generating formulas from the microgrid component graph. - """ - microgrid_configs = MicrogridConfig.load_configs_from_files( - microgrid_config_files=microgrid_config_files, - microgrid_config_dir=microgrid_config_dir, - ) - - for microgrid_id, config in microgrid_configs.items(): - await populate_missing_formulas( - microgrid_id=int(microgrid_id), - config=config, - assets_client=assets_client, - component_types=set(config.ctype.keys()), - ) - - return microgrid_configs - def merge_microgrid_configs( base: MicrogridConfig, @@ -592,14 +544,14 @@ async def _build_config_from_metadata( """Build a base config for a microgrid from its Assets API metadata. Fetches the microgrid's location and returns a `MicrogridConfig` carrying - only metadata; formulas are populated separately. + only metadata; formulas are derived separately. Args: assets_client: Assets API client used to fetch the microgrid. microgrid_id: ID of the microgrid to fetch. Returns: - A `MicrogridConfig` with metadata populated from the Assets API. + A `MicrogridConfig` with metadata from the Assets API. """ mgrid = await assets_client.get_microgrid(MicrogridId(microgrid_id)) location = mgrid.location if mgrid.location else None From 55d19f98b1589cf0f54d153ba3e763af68f2dedd Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:28:03 +0200 Subject: [PATCH 05/11] refactor(config): rename load_configs_from_assets_api to load_configs_from_api The "assets_api" suffix was redundant with the module's purpose; the shorter name reads better at call sites. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/_microgrid_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/_microgrid_config.py index 9105fe5..442ba3b 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/_microgrid_config.py @@ -422,7 +422,7 @@ def load_configs_from_files( return microgrid_configs @staticmethod - async def load_configs_from_assets_api( + async def load_configs_from_api( assets_client: AssetsApiClient, microgrid_ids: list[int], populate_formulas: bool = True, From b8f3a57ec6ae1932a2ff00ddc97f312642dd5c9d Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:32:18 +0200 Subject: [PATCH 06/11] refactor(config): promote the config loaders to module-level functions load_configs_from_files and load_configs_from_api are static and return a map of configs rather than an instance, so they were loader functions wearing MicrogridConfig as a namespace. Make them module-level functions and export them from the package. This is a behaviour-preserving move: the function bodies are unchanged. The only edits beyond relocating the code are the mechanical consequences of the move. load_from_file stays a classmethod, since it is single-file deserialization of the model. This also sets up splitting the data model and the loading logic into separate modules. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/__init__.py | 4 + src/frequenz/gridpool/_microgrid_config.py | 190 ++++++++++----------- tests/test_config.py | 7 +- 3 files changed, 104 insertions(+), 97 deletions(-) diff --git a/src/frequenz/gridpool/__init__.py b/src/frequenz/gridpool/__init__.py index c539309..e9cabfb 100644 --- a/src/frequenz/gridpool/__init__.py +++ b/src/frequenz/gridpool/__init__.py @@ -7,6 +7,8 @@ from ._microgrid_config import ( Metadata, MicrogridConfig, + load_configs_from_api, + load_configs_from_files, merge_config_maps, merge_microgrid_configs, ) @@ -15,6 +17,8 @@ "ComponentGraphGenerator", "Metadata", "MicrogridConfig", + "load_configs_from_api", + "load_configs_from_files", "merge_config_maps", "merge_microgrid_configs", ] diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/_microgrid_config.py index 442ba3b..8689154 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/_microgrid_config.py @@ -358,121 +358,121 @@ def load_from_file(cls, config_path: Path) -> dict[str, Self]: return cls._load_table_entries(data) - @staticmethod - def load_configs_from_files( - microgrid_config_files: str | Path | list[str | Path] | None = None, - microgrid_config_dir: str | Path | None = None, - ) -> dict[str, "MicrogridConfig"]: - """Load multiple microgrid configurations from a file. - Configs for a single microgrid are expected to be in a single file. - Later files with the same microgrid ID will overwrite the previous configs. +def load_configs_from_files( + microgrid_config_files: str | Path | list[str | Path] | None = None, + microgrid_config_dir: str | Path | None = None, +) -> dict[str, "MicrogridConfig"]: + """Load multiple microgrid configurations from a file. - Args: - microgrid_config_files: Path to a single microgrid config file or list of paths. - microgrid_config_dir: Directory containing multiple microgrid config files. + Configs for a single microgrid are expected to be in a single file. + Later files with the same microgrid ID will overwrite the previous configs. - Returns: - Dictionary of single microgrid formula configs with microgrid IDs as keys. + Args: + microgrid_config_files: Path to a single microgrid config file or list of paths. + microgrid_config_dir: Directory containing multiple microgrid config files. - Raises: - ValueError: If no config files or dir is provided, or if no config files are found. - """ - if microgrid_config_files is None and microgrid_config_dir is None: - raise ValueError( - "No microgrid config path or directory provided. " - "Please provide at least one." - ) + Returns: + Dictionary of single microgrid formula configs with microgrid IDs as keys. - config_files: list[Path] = [] + Raises: + ValueError: If no config files or dir is provided, or if no config files are found. + """ + if microgrid_config_files is None and microgrid_config_dir is None: + raise ValueError( + "No microgrid config path or directory provided. " + "Please provide at least one." + ) - if microgrid_config_files: - if isinstance(microgrid_config_files, str): - config_files = [Path(microgrid_config_files)] - elif isinstance(microgrid_config_files, Path): - config_files = [microgrid_config_files] - elif isinstance(microgrid_config_files, list): - config_files = [Path(f) for f in microgrid_config_files] + config_files: list[Path] = [] - if microgrid_config_dir: - if Path(microgrid_config_dir).is_dir(): - config_files += list(Path(microgrid_config_dir).glob("*.toml")) - else: - raise ValueError( - f"Microgrid config directory {microgrid_config_dir} " - "is not a directory" - ) + if microgrid_config_files: + if isinstance(microgrid_config_files, str): + config_files = [Path(microgrid_config_files)] + elif isinstance(microgrid_config_files, Path): + config_files = [microgrid_config_files] + elif isinstance(microgrid_config_files, list): + config_files = [Path(f) for f in microgrid_config_files] - if len(config_files) == 0: + if microgrid_config_dir: + if Path(microgrid_config_dir).is_dir(): + config_files += list(Path(microgrid_config_dir).glob("*.toml")) + else: raise ValueError( - "No microgrid config files found. " - "Please provide at least one valid config file." + f"Microgrid config directory {microgrid_config_dir} " + "is not a directory" ) - microgrid_configs: dict[str, "MicrogridConfig"] = {} + if len(config_files) == 0: + raise ValueError( + "No microgrid config files found. " + "Please provide at least one valid config file." + ) - for config_path in config_files: - if not config_path.is_file(): - _logger.warning("Config path %s is not a file, skipping.", config_path) - continue + microgrid_configs: dict[str, "MicrogridConfig"] = {} - mcfgs = MicrogridConfig.load_from_file(config_path) - microgrid_configs.update({str(key): value for key, value in mcfgs.items()}) + for config_path in config_files: + if not config_path.is_file(): + _logger.warning("Config path %s is not a file, skipping.", config_path) + continue - return microgrid_configs + mcfgs = MicrogridConfig.load_from_file(config_path) + microgrid_configs.update({str(key): value for key, value in mcfgs.items()}) - @staticmethod - 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. + return microgrid_configs - 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_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. +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. - 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. - """ - 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", - microgrid_id, - exc, + 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_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. + """ + 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, ) - continue - configs[str(microgrid_id)] = cfg + 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 + return configs def merge_microgrid_configs( diff --git a/tests/test_config.py b/tests/test_config.py index 1a688ae..f344cbf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,7 +10,10 @@ from pytest_mock import MockerFixture from frequenz.gridpool import MicrogridConfig -from frequenz.gridpool._microgrid_config import ComponentTypeConfig +from frequenz.gridpool._microgrid_config import ( + ComponentTypeConfig, + load_configs_from_files, +) VALID_CONFIG: dict[str, dict[str, Any]] = { "1": { @@ -128,7 +131,7 @@ def test_load_configs(mocker: MockerFixture) -> None: mock_file = mocker.mock_open(read_data=toml_data.encode("utf-8")) mocker.patch("pathlib.Path.open", mock_file) mocker.patch("pathlib.Path.is_file", mocker.Mock(return_value=True)) - configs = MicrogridConfig.load_configs_from_files(Path("mock_path.toml")) + configs = load_configs_from_files(Path("mock_path.toml")) assert "1" in configs assert configs["1"].meta is not None From 91489e18e367d7ef8984e2360b3b95848cfdf013 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:33:27 +0200 Subject: [PATCH 07/11] refactor(config): add load_configs merge wrapper A thin wrapper that composes the source loaders and combines them via merge_config_maps in explicit layers supporting a default, API and override structure. The caller picks a strategy by choosing which sources to pass. The Assets API is fetched for the IDs in microgrid_ids when given, otherwise the IDs found in the files, so it works with no files at all. File values win over the Assets API only where they are set; everything else is filled in. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/__init__.py | 2 + src/frequenz/gridpool/_microgrid_config.py | 79 ++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/src/frequenz/gridpool/__init__.py b/src/frequenz/gridpool/__init__.py index e9cabfb..9f85d1c 100644 --- a/src/frequenz/gridpool/__init__.py +++ b/src/frequenz/gridpool/__init__.py @@ -7,6 +7,7 @@ from ._microgrid_config import ( Metadata, MicrogridConfig, + load_configs, load_configs_from_api, load_configs_from_files, merge_config_maps, @@ -17,6 +18,7 @@ "ComponentGraphGenerator", "Metadata", "MicrogridConfig", + "load_configs", "load_configs_from_api", "load_configs_from_files", "merge_config_maps", diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/_microgrid_config.py index 8689154..1e4a086 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/_microgrid_config.py @@ -475,6 +475,85 @@ async def load_configs_from_api( return configs +async def load_configs( + default_files: str | Path | list[str | Path] | None = None, + assets_client: AssetsApiClient | None = None, + override_files: str | Path | list[str | Path] | None = None, + microgrid_ids: list[int] | None = None, +) -> dict[str, "MicrogridConfig"]: + """Load configs from up to three sources and merge them in layers. + + Combines up to three sources, listed here from lowest to highest + precedence: a *default* config file layer, the Assets API, and an + *override* config file layer. Higher layers win on conflicts, while + lower layers fill in anything the higher ones leave unset. This lets + callers pick a strategy by choosing which sources to pass, for example: + + - `default_files` + `assets_client`: files provide defaults that the + Assets API overrides. + - `assets_client` + `override_files`: the Assets API provides the base + that files override. + - all three: the Assets API sits between a default and an override file + layer. + + The microgrid IDs fetched from the Assets API are `microgrid_ids` when + given, otherwise the IDs found in the default and override files. This + lets the Assets API layer be used even when no files are given. + + Args: + default_files: + Optional path or list of paths to config files forming the + lowest-precedence layer. + assets_client: + Optional Assets API client. When given, microgrid metadata and + formulas are fetched and layered above the default files. + override_files: + Optional path or list of paths to config files forming the + highest-precedence layer. + microgrid_ids: + 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. + + Returns: + dict[str, MicrogridConfig]: + Mapping from microgrid ID (as string) to the merged + `MicrogridConfig` instance. + + Raises: + ValueError: If none of the three sources is provided, or if + `microgrid_ids` 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.") + + if microgrid_ids is not None and assets_client is None: + raise ValueError("microgrid_ids requires an assets_client.") + + configs: dict[str, MicrogridConfig] = {} + if default_files is not None: + configs = load_configs_from_files( + microgrid_config_files=default_files, + ) + + override_configs: dict[str, MicrogridConfig] = {} + if override_files is not None: + override_configs = load_configs_from_files( + microgrid_config_files=override_files, + ) + + if assets_client is not None: + if microgrid_ids is None: + microgrid_ids = sorted({int(mid) for mid in (*configs, *override_configs)}) + assets_configs = await load_configs_from_api( + assets_client=assets_client, + microgrid_ids=microgrid_ids, + ) + configs = merge_config_maps(base=configs, override=assets_configs) + + return merge_config_maps(base=configs, override=override_configs) + + def merge_microgrid_configs( base: MicrogridConfig, override: MicrogridConfig, From e4f44c4e4f3fa17bf8c9ac34d638419fb69c7c9c Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:39:48 +0200 Subject: [PATCH 08/11] refactor(config): split the config module into a config package Move the microgrid config module into a config package split along the data model and load helpers. The package's init re-exports the public surface so import paths are unchanged for callers. No behavioral change. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/__init__.py | 2 +- src/frequenz/gridpool/config/__init__.py | 38 ++ src/frequenz/gridpool/config/load.py | 325 ++++++++++++++++++ .../microgrid.py} | 312 +---------------- tests/test_config.py | 5 +- 5 files changed, 367 insertions(+), 315 deletions(-) create mode 100644 src/frequenz/gridpool/config/__init__.py create mode 100644 src/frequenz/gridpool/config/load.py rename src/frequenz/gridpool/{_microgrid_config.py => config/microgrid.py} (52%) diff --git a/src/frequenz/gridpool/__init__.py b/src/frequenz/gridpool/__init__.py index 9f85d1c..10dc0b9 100644 --- a/src/frequenz/gridpool/__init__.py +++ b/src/frequenz/gridpool/__init__.py @@ -4,7 +4,7 @@ """High-level interface to grid pools for the Frequenz platform.""" from ._graph_generator import ComponentGraphGenerator -from ._microgrid_config import ( +from .config import ( Metadata, MicrogridConfig, load_configs, diff --git a/src/frequenz/gridpool/config/__init__.py b/src/frequenz/gridpool/config/__init__.py new file mode 100644 index 0000000..edbf760 --- /dev/null +++ b/src/frequenz/gridpool/config/__init__.py @@ -0,0 +1,38 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Microgrid configuration data model and loading.""" + +from .load import ( + load_configs, + load_configs_from_api, + load_configs_from_files, +) +from .microgrid import ( + BatteryConfig, + ComponentCategory, + ComponentType, + ComponentTypeConfig, + Metadata, + MicrogridConfig, + PVConfig, + WindConfig, + merge_config_maps, + merge_microgrid_configs, +) + +__all__ = [ + "BatteryConfig", + "ComponentCategory", + "ComponentType", + "ComponentTypeConfig", + "Metadata", + "MicrogridConfig", + "PVConfig", + "WindConfig", + "load_configs", + "load_configs_from_api", + "load_configs_from_files", + "merge_config_maps", + "merge_microgrid_configs", +] diff --git a/src/frequenz/gridpool/config/load.py b/src/frequenz/gridpool/config/load.py new file mode 100644 index 0000000..e96c28b --- /dev/null +++ b/src/frequenz/gridpool/config/load.py @@ -0,0 +1,325 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading and merging of microgrid configurations.""" + +import logging +from pathlib import Path + +from frequenz.client.assets import AssetsApiClient +from frequenz.client.common.microgrid import MicrogridId + +from .._graph_generator import ComponentGraphGenerator +from .microgrid import ( + ComponentTypeConfig, + Metadata, + MicrogridConfig, + merge_config_maps, +) + +_logger = logging.getLogger(__name__) + + +async def load_configs( + default_files: str | Path | list[str | Path] | None = None, + assets_client: AssetsApiClient | None = None, + override_files: str | Path | list[str | Path] | None = None, + microgrid_ids: list[int] | None = None, +) -> dict[str, "MicrogridConfig"]: + """Load configs from up to three sources and merge them in layers. + + Combines up to three sources, listed here from lowest to highest + precedence: a *default* config file layer, the Assets API, and an + *override* config file layer. Higher layers win on conflicts, while + lower layers fill in anything the higher ones leave unset. This lets + callers pick a strategy by choosing which sources to pass, for example: + + - `default_files` + `assets_client`: files provide defaults that the + Assets API overrides. + - `assets_client` + `override_files`: the Assets API provides the base + that files override. + - all three: the Assets API sits between a default and an override file + layer. + + The microgrid IDs fetched from the Assets API are `microgrid_ids` when + given, otherwise the IDs found in the default and override files. This + lets the Assets API layer be used even when no files are given. + + Args: + default_files: + Optional path or list of paths to config files forming the + lowest-precedence layer. + assets_client: + Optional Assets API client. When given, microgrid metadata and + formulas are fetched and layered above the default files. + override_files: + Optional path or list of paths to config files forming the + highest-precedence layer. + microgrid_ids: + 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. + + Returns: + dict[str, MicrogridConfig]: + Mapping from microgrid ID (as string) to the merged + `MicrogridConfig` instance. + + Raises: + ValueError: If none of the three sources is provided, or if + `microgrid_ids` 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.") + + if microgrid_ids is not None and assets_client is None: + raise ValueError("microgrid_ids requires an assets_client.") + + configs: dict[str, MicrogridConfig] = {} + if default_files is not None: + configs = load_configs_from_files( + microgrid_config_files=default_files, + ) + + override_configs: dict[str, MicrogridConfig] = {} + if override_files is not None: + override_configs = load_configs_from_files( + microgrid_config_files=override_files, + ) + + if assets_client is not None: + if microgrid_ids is None: + microgrid_ids = sorted({int(mid) for mid in (*configs, *override_configs)}) + assets_configs = await load_configs_from_api( + assets_client=assets_client, + microgrid_ids=microgrid_ids, + ) + configs = merge_config_maps(base=configs, override=assets_configs) + + return merge_config_maps(base=configs, override=override_configs) + + +def load_configs_from_files( + microgrid_config_files: str | Path | list[str | Path] | None = None, + microgrid_config_dir: str | Path | None = None, +) -> dict[str, "MicrogridConfig"]: + """Load multiple microgrid configurations from a file. + + Configs for a single microgrid are expected to be in a single file. + Later files with the same microgrid ID will overwrite the previous configs. + + Args: + microgrid_config_files: Path to a single microgrid config file or list of paths. + microgrid_config_dir: Directory containing multiple microgrid config files. + + Returns: + Dictionary of single microgrid formula configs with microgrid IDs as keys. + + Raises: + ValueError: If no config files or dir is provided, or if no config files are found. + """ + if microgrid_config_files is None and microgrid_config_dir is None: + raise ValueError( + "No microgrid config path or directory provided. " + "Please provide at least one." + ) + + config_files: list[Path] = [] + + if microgrid_config_files: + if isinstance(microgrid_config_files, str): + config_files = [Path(microgrid_config_files)] + elif isinstance(microgrid_config_files, Path): + config_files = [microgrid_config_files] + elif isinstance(microgrid_config_files, list): + config_files = [Path(f) for f in microgrid_config_files] + + if microgrid_config_dir: + if Path(microgrid_config_dir).is_dir(): + config_files += list(Path(microgrid_config_dir).glob("*.toml")) + else: + raise ValueError( + f"Microgrid config directory {microgrid_config_dir} " + "is not a directory" + ) + + if len(config_files) == 0: + raise ValueError( + "No microgrid config files found. " + "Please provide at least one valid config file." + ) + + microgrid_configs: dict[str, "MicrogridConfig"] = {} + + for config_path in config_files: + if not config_path.is_file(): + _logger.warning("Config path %s is not a file, skipping.", config_path) + continue + + mcfgs = MicrogridConfig.load_from_file(config_path) + microgrid_configs.update({str(key): value for key, value in mcfgs.items()}) + + return microgrid_configs + + +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. + + 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_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. + """ + 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", + microgrid_id, + exc, + ) + continue + configs[str(microgrid_id)] = cfg + + return configs + + +async def _build_config_from_metadata( + assets_client: AssetsApiClient, microgrid_id: int +) -> MicrogridConfig: + """Build a base config for a microgrid from its Assets API metadata. + + Fetches the microgrid's location and returns a `MicrogridConfig` carrying + only metadata; formulas are derived separately. + + Args: + assets_client: Assets API client used to fetch the microgrid. + microgrid_id: ID of the microgrid to fetch. + + Returns: + A `MicrogridConfig` with metadata from the Assets API. + """ + mgrid = await assets_client.get_microgrid(MicrogridId(microgrid_id)) + location = mgrid.location if mgrid.location else None + return MicrogridConfig( + meta=Metadata( + microgrid_id=microgrid_id, + latitude=location.latitude if location else None, + longitude=location.longitude if location else None, + ) + ) + + +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. + + 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. + + 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. + + 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. + """ + 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 + + if cfg.formula is None: + cfg.formula = {} + + if "AC_POWER_ACTIVE" not in cfg.formula: + cfg.formula["AC_POWER_ACTIVE"] = formula diff --git a/src/frequenz/gridpool/_microgrid_config.py b/src/frequenz/gridpool/config/microgrid.py similarity index 52% rename from src/frequenz/gridpool/_microgrid_config.py rename to src/frequenz/gridpool/config/microgrid.py index 1e4a086..faf43c7 100644 --- a/src/frequenz/gridpool/_microgrid_config.py +++ b/src/frequenz/gridpool/config/microgrid.py @@ -1,7 +1,7 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Configuration for microgrids.""" +"""Data model for microgrid configurations.""" import logging import re @@ -12,15 +12,12 @@ from pathlib import Path from typing import Any, ClassVar, Literal, Self, Type, cast, get_args -from frequenz.client.assets import AssetsApiClient -from frequenz.client.common.microgrid import MicrogridId from marshmallow import Schema from marshmallow_dataclass import dataclass -from ._graph_generator import ComponentGraphGenerator - _logger = logging.getLogger(__name__) + ComponentType = Literal["grid", "pv", "battery", "consumption", "chp", "ev"] """Valid component types.""" @@ -359,201 +356,6 @@ def load_from_file(cls, config_path: Path) -> dict[str, Self]: return cls._load_table_entries(data) -def load_configs_from_files( - microgrid_config_files: str | Path | list[str | Path] | None = None, - microgrid_config_dir: str | Path | None = None, -) -> dict[str, "MicrogridConfig"]: - """Load multiple microgrid configurations from a file. - - Configs for a single microgrid are expected to be in a single file. - Later files with the same microgrid ID will overwrite the previous configs. - - Args: - microgrid_config_files: Path to a single microgrid config file or list of paths. - microgrid_config_dir: Directory containing multiple microgrid config files. - - Returns: - Dictionary of single microgrid formula configs with microgrid IDs as keys. - - Raises: - ValueError: If no config files or dir is provided, or if no config files are found. - """ - if microgrid_config_files is None and microgrid_config_dir is None: - raise ValueError( - "No microgrid config path or directory provided. " - "Please provide at least one." - ) - - config_files: list[Path] = [] - - if microgrid_config_files: - if isinstance(microgrid_config_files, str): - config_files = [Path(microgrid_config_files)] - elif isinstance(microgrid_config_files, Path): - config_files = [microgrid_config_files] - elif isinstance(microgrid_config_files, list): - config_files = [Path(f) for f in microgrid_config_files] - - if microgrid_config_dir: - if Path(microgrid_config_dir).is_dir(): - config_files += list(Path(microgrid_config_dir).glob("*.toml")) - else: - raise ValueError( - f"Microgrid config directory {microgrid_config_dir} " - "is not a directory" - ) - - if len(config_files) == 0: - raise ValueError( - "No microgrid config files found. " - "Please provide at least one valid config file." - ) - - microgrid_configs: dict[str, "MicrogridConfig"] = {} - - for config_path in config_files: - if not config_path.is_file(): - _logger.warning("Config path %s is not a file, skipping.", config_path) - continue - - mcfgs = MicrogridConfig.load_from_file(config_path) - microgrid_configs.update({str(key): value for key, value in mcfgs.items()}) - - return microgrid_configs - - -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. - - 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_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. - """ - 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", - microgrid_id, - exc, - ) - continue - configs[str(microgrid_id)] = cfg - - return configs - - -async def load_configs( - default_files: str | Path | list[str | Path] | None = None, - assets_client: AssetsApiClient | None = None, - override_files: str | Path | list[str | Path] | None = None, - microgrid_ids: list[int] | None = None, -) -> dict[str, "MicrogridConfig"]: - """Load configs from up to three sources and merge them in layers. - - Combines up to three sources, listed here from lowest to highest - precedence: a *default* config file layer, the Assets API, and an - *override* config file layer. Higher layers win on conflicts, while - lower layers fill in anything the higher ones leave unset. This lets - callers pick a strategy by choosing which sources to pass, for example: - - - `default_files` + `assets_client`: files provide defaults that the - Assets API overrides. - - `assets_client` + `override_files`: the Assets API provides the base - that files override. - - all three: the Assets API sits between a default and an override file - layer. - - The microgrid IDs fetched from the Assets API are `microgrid_ids` when - given, otherwise the IDs found in the default and override files. This - lets the Assets API layer be used even when no files are given. - - Args: - default_files: - Optional path or list of paths to config files forming the - lowest-precedence layer. - assets_client: - Optional Assets API client. When given, microgrid metadata and - formulas are fetched and layered above the default files. - override_files: - Optional path or list of paths to config files forming the - highest-precedence layer. - microgrid_ids: - 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. - - Returns: - dict[str, MicrogridConfig]: - Mapping from microgrid ID (as string) to the merged - `MicrogridConfig` instance. - - Raises: - ValueError: If none of the three sources is provided, or if - `microgrid_ids` 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.") - - if microgrid_ids is not None and assets_client is None: - raise ValueError("microgrid_ids requires an assets_client.") - - configs: dict[str, MicrogridConfig] = {} - if default_files is not None: - configs = load_configs_from_files( - microgrid_config_files=default_files, - ) - - override_configs: dict[str, MicrogridConfig] = {} - if override_files is not None: - override_configs = load_configs_from_files( - microgrid_config_files=override_files, - ) - - if assets_client is not None: - if microgrid_ids is None: - microgrid_ids = sorted({int(mid) for mid in (*configs, *override_configs)}) - assets_configs = await load_configs_from_api( - assets_client=assets_client, - microgrid_ids=microgrid_ids, - ) - configs = merge_config_maps(base=configs, override=assets_configs) - - return merge_config_maps(base=configs, override=override_configs) - - def merge_microgrid_configs( base: MicrogridConfig, override: MicrogridConfig, @@ -615,113 +417,3 @@ def merge_config_maps( else: merged[mid] = cfg return merged - - -async def _build_config_from_metadata( - assets_client: AssetsApiClient, microgrid_id: int -) -> MicrogridConfig: - """Build a base config for a microgrid from its Assets API metadata. - - Fetches the microgrid's location and returns a `MicrogridConfig` carrying - only metadata; formulas are derived separately. - - Args: - assets_client: Assets API client used to fetch the microgrid. - microgrid_id: ID of the microgrid to fetch. - - Returns: - A `MicrogridConfig` with metadata from the Assets API. - """ - mgrid = await assets_client.get_microgrid(MicrogridId(microgrid_id)) - location = mgrid.location if mgrid.location else None - return MicrogridConfig( - meta=Metadata( - microgrid_id=microgrid_id, - latitude=location.latitude if location else None, - longitude=location.longitude if location else None, - ) - ) - - -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. - - 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. - - 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. - - 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. - """ - 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 - - if cfg.formula is None: - cfg.formula = {} - - if "AC_POWER_ACTIVE" not in cfg.formula: - cfg.formula["AC_POWER_ACTIVE"] = formula diff --git a/tests/test_config.py b/tests/test_config.py index f344cbf..184c761 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,10 +10,7 @@ from pytest_mock import MockerFixture from frequenz.gridpool import MicrogridConfig -from frequenz.gridpool._microgrid_config import ( - ComponentTypeConfig, - load_configs_from_files, -) +from frequenz.gridpool.config import ComponentTypeConfig, load_configs_from_files VALID_CONFIG: dict[str, dict[str, Any]] = { "1": { From e5e77122bb37a4807440d4b5fd830f6064155262 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:05:33 +0200 Subject: [PATCH 09/11] refactor(config): make populate_missing_formulas private It is an internal helper of the Assets API loader, not part of the public API, so prefix it with an underscore and update the call site in load_configs_from_api. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/config/load.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/frequenz/gridpool/config/load.py b/src/frequenz/gridpool/config/load.py index e96c28b..9b3b5a3 100644 --- a/src/frequenz/gridpool/config/load.py +++ b/src/frequenz/gridpool/config/load.py @@ -183,7 +183,7 @@ async def load_configs_from_api( 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 + `_populate_missing_formulas`. Set to `False` to load metadata only. Returns: @@ -198,7 +198,7 @@ async def load_configs_from_api( try: cfg = await _build_config_from_metadata(assets_client, microgrid_id) if populate_formulas: - await populate_missing_formulas( + await _populate_missing_formulas( microgrid_id=microgrid_id, config=cfg, assets_client=assets_client, @@ -256,7 +256,7 @@ def _is_zero_formula(formula: str) -> bool: return False -async def populate_missing_formulas( +async def _populate_missing_formulas( microgrid_id: int, config: "MicrogridConfig", assets_client: AssetsApiClient, From ae0b7febc1100a374714d9f27a337fbdfca4135d Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:05:00 +0200 Subject: [PATCH 10/11] refactor(config): drop directory loading from load_configs_from_files The microgrid_config_dir option was just a short version of passing a list with the file content of a folder. Dropping it leaves the loader one clear input and removes a baked-in "*.toml", non-recursive glob convention, letting callers choose how to collect their files. Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- src/frequenz/gridpool/config/load.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/frequenz/gridpool/config/load.py b/src/frequenz/gridpool/config/load.py index 9b3b5a3..f97c705 100644 --- a/src/frequenz/gridpool/config/load.py +++ b/src/frequenz/gridpool/config/load.py @@ -101,27 +101,24 @@ async def load_configs( def load_configs_from_files( microgrid_config_files: str | Path | list[str | Path] | None = None, - microgrid_config_dir: str | Path | None = None, ) -> dict[str, "MicrogridConfig"]: - """Load multiple microgrid configurations from a file. + """Load multiple microgrid configurations from one or more files. Configs for a single microgrid are expected to be in a single file. Later files with the same microgrid ID will overwrite the previous configs. Args: microgrid_config_files: Path to a single microgrid config file or list of paths. - microgrid_config_dir: Directory containing multiple microgrid config files. Returns: Dictionary of single microgrid formula configs with microgrid IDs as keys. Raises: - ValueError: If no config files or dir is provided, or if no config files are found. + ValueError: If no config files are provided, or if no config files are found. """ - if microgrid_config_files is None and microgrid_config_dir is None: + if microgrid_config_files is None: raise ValueError( - "No microgrid config path or directory provided. " - "Please provide at least one." + "No microgrid config files provided. Please provide at least one." ) config_files: list[Path] = [] @@ -134,15 +131,6 @@ def load_configs_from_files( elif isinstance(microgrid_config_files, list): config_files = [Path(f) for f in microgrid_config_files] - if microgrid_config_dir: - if Path(microgrid_config_dir).is_dir(): - config_files += list(Path(microgrid_config_dir).glob("*.toml")) - else: - raise ValueError( - f"Microgrid config directory {microgrid_config_dir} " - "is not a directory" - ) - if len(config_files) == 0: raise ValueError( "No microgrid config files found. " From 427502541cf2138152f516640468132ef7632719 Mon Sep 17 00:00:00 2001 From: cwasicki <126617870+cwasicki@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:05:32 +0200 Subject: [PATCH 11/11] docs: Update release notes Signed-off-by: cwasicki <126617870+cwasicki@users.noreply.github.com> --- RELEASE_NOTES.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 698e411..03223e2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,15 +2,21 @@ ## 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. +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. ## Upgrading - +The configuration loaders are no longer `MicrogridConfig` static methods — they are module-level functions exported from `frequenz.gridpool`, and their signatures have changed: + +* `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. ## New Features -* 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 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(...)` 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.