Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
- Microgrid config files should nest their entries under `assets.microgrids`:

```toml
assets.microgrids.23.meta.name = "..."
```

Bare microgrid IDs at the top level still load but log a deprecation warning,
and support for them will be removed. The namespace keeps generated inventory
data apart from operator settings under `app.*`, so a config file can carry
both without its keys colliding.

A file mixing both layouts is rejected.

## New Features

Expand Down
53 changes: 49 additions & 4 deletions src/frequenz/gridpool/config/microgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,18 +301,18 @@ def _load_table_entries(cls, data: dict[str, Any]) -> dict[str, Self]:
"""Load microgrid configurations from table entries.

Args:
data: The loaded TOML data.
data: The table mapping microgrid IDs to their entries.

Returns:
A dict mapping microgrid IDs to MicrogridConfig instances.

Raises:
ValueError: If top-level keys are not numeric microgrid IDs
ValueError: If the keys are not numeric microgrid IDs
or if there is a microgrid ID mismatch.
TypeError: If microgrid data is not a dict.
"""
if not all(str(k).isdigit() for k in data.keys()):
raise ValueError("All top-level keys must be numeric microgrid IDs.")
raise ValueError("All microgrid keys must be numeric microgrid IDs.")

mgrids = {}
for mid, entry in data.items():
Expand All @@ -337,6 +337,51 @@ def _load_table_entries(cls, data: dict[str, Any]) -> dict[str, Self]:

return mgrids

@classmethod
def _microgrid_table(cls, data: dict[str, Any], source: str) -> dict[str, Any]:
"""Pick the microgrid entries out of a parsed config document.

Entries live under `assets.microgrids`. A document without an `assets`
table is read in the deprecated layout, where the entries sit at the
top level.

Args:
data: The parsed TOML document.
source: Name of the document, used in messages.

Returns:
The table mapping microgrid IDs to their entries.

Raises:
TypeError: If `assets` is not a table.
ValueError: If both layouts are present, which means a
half-migrated file rather than a merge.
"""
if "assets" not in data:
_logger.warning(
"%s: top-level microgrid IDs are deprecated, "
"nest the entries under `assets.microgrids` instead.",
source,
)
return data

assets = data["assets"]
if not isinstance(assets, dict):
raise TypeError(f"{source}: `assets` must be a table, got {type(assets)}")

if unprefixed := sorted(k for k in data if k != "assets"):
raise ValueError(
f"{source}: keys {unprefixed} sit outside `assets` while the file "
"already has an `assets` table; move them under `assets.microgrids`."
)

microgrids = assets.get("microgrids", {})
if not isinstance(microgrids, dict):
raise TypeError(
f"{source}: `assets.microgrids` must be a table, got {type(microgrids)}"
)
return microgrids

@classmethod
def load_from_file(cls, config_path: Path) -> dict[str, Self]:
"""
Expand All @@ -353,7 +398,7 @@ def load_from_file(cls, config_path: Path) -> dict[str, Self]:

assert isinstance(data, dict)

return cls._load_table_entries(data)
return cls._load_table_entries(cls._microgrid_table(data, str(config_path)))


def merge_microgrid_configs(
Expand Down
82 changes: 81 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@

"""Tests for the frequenz.lib.notebooks.config module."""

import logging
from pathlib import Path
from typing import Any

import pytest
from pytest_mock import MockerFixture

from frequenz.gridpool import MicrogridConfig
from frequenz.gridpool.config import ComponentTypeConfig, load_configs_from_files
from frequenz.gridpool.config import (
ComponentTypeConfig,
load_configs,
load_configs_from_files,
)

VALID_CONFIG: dict[str, dict[str, Any]] = {
"1": {
Expand Down Expand Up @@ -160,3 +165,78 @@ def _assert_optional_field(value: float | None, expected: float) -> None:
if value is not None:
if value != expected:
raise AssertionError(f"Expected {expected}, got {value}")


_PREFIXED_TOML = """
assets.microgrids.1.meta.microgrid_id = 1
assets.microgrids.1.meta.name = "Test Grid"
assets.microgrids.1.ctype.pv.meter = [101, 102]
"""

_LEGACY_TOML = """
1.meta.microgrid_id = 1
1.meta.name = "Test Grid"
1.ctype.pv.meter = [101, 102]
"""


def _write(tmp_path: Path, name: str, text: str) -> Path:
"""Write `text` to `name` under `tmp_path` and return the path."""
path = tmp_path / name
path.write_text(text)
return path


def test_load_prefixed(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Entries under `assets.microgrids` load without a deprecation warning."""
configs = MicrogridConfig.load_from_file(
_write(tmp_path, "prefixed.toml", _PREFIXED_TOML)
)

assert configs["1"].meta.name == "Test Grid"
assert configs["1"].component_type_ids("pv") == [101, 102]
assert "deprecated" not in caplog.text


def test_load_legacy_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Top-level entries still load, but warn and name the file."""
path = _write(tmp_path, "legacy.toml", _LEGACY_TOML)

with caplog.at_level(logging.WARNING):
configs = MicrogridConfig.load_from_file(path)

assert configs["1"].meta.name == "Test Grid"
assert "deprecated" in caplog.text
assert str(path) in caplog.text


def test_load_mixed_layouts_rejected(tmp_path: Path) -> None:
"""A half-migrated file with both layouts is an error, not a merge."""
path = _write(
tmp_path,
"mixed.toml",
_PREFIXED_TOML + '2.meta.microgrid_id = 2\n2.meta.name = "Other Grid"\n',
)

with pytest.raises(ValueError, match="outside `assets`"):
MicrogridConfig.load_from_file(path)


def test_load_assets_without_microgrids(tmp_path: Path) -> None:
"""An `assets` table without microgrids yields no configs and no warning."""
path = _write(tmp_path, "other.toml", 'assets.gridpool.7.name = "GP"\n')

assert not MicrogridConfig.load_from_file(path)


async def test_merge_prefixed_base_with_legacy_override(tmp_path: Path) -> None:
"""Layers of either layout merge as usual, since layout is resolved on load."""
base = _write(tmp_path, "base.toml", _PREFIXED_TOML)
override = _write(
tmp_path, "override.toml", '1.meta.microgrid_id = 1\n1.meta.name = "Renamed"\n'
)

configs = await load_configs(default_files=base, override_files=override)

assert configs["1"].meta.name == "Renamed"
assert configs["1"].component_type_ids("pv") == [101, 102]