Skip to content

Commit 04d5de2

Browse files
committed
Switch to schema-based microgrid config loading
Previously, microgrid configs were initialized by unpacking nested dictionaries. Now the class defines a marshmallow schema and uses it to deserialize toml data into valudated python objects. Introduces a new `load_from_file` function to load multiple configs as dict from a single file. This is also used now in the existing function to load multiple files or directories. Signed-off-by: cwasicki <[email protected]>
1 parent ff28400 commit 04d5de2

File tree

2 files changed

+82
-41
lines changed

2 files changed

+82
-41
lines changed

src/frequenz/data/microgrid/config.py

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
import tomllib
99
from dataclasses import field
1010
from pathlib import Path
11-
from typing import Any, Literal, cast, get_args
11+
from typing import Any, ClassVar, Literal, Self, Type, cast, get_args
1212

13+
from marshmallow import Schema
1314
from marshmallow_dataclass import dataclass
1415

1516
_logger = logging.getLogger(__name__)
@@ -198,24 +199,6 @@ class MicrogridConfig:
198199
ctype: dict[str, ComponentTypeConfig] = field(default_factory=dict)
199200
"""Mapping of component category types to ac power component config."""
200201

201-
def __init__(self, config_dict: dict[str, Any]) -> None:
202-
"""Initialize the microgrid configuration.
203-
204-
Args:
205-
config_dict: Dictionary with component type as key and config as value.
206-
"""
207-
self.meta = Metadata(**(config_dict.get("meta") or {}))
208-
209-
self.pv = config_dict.get("pv") or {}
210-
self.wind = config_dict.get("wind") or {}
211-
self.battery = config_dict.get("battery") or {}
212-
213-
self.ctype = {
214-
ctype: ComponentTypeConfig(**cfg)
215-
for ctype, cfg in config_dict.get("ctype", {}).items()
216-
if ComponentTypeConfig.is_valid_type(ctype)
217-
}
218-
219202
def component_types(self) -> list[str]:
220203
"""Get a list of all component types in the configuration."""
221204
return list(self.ctype.keys())
@@ -282,6 +265,67 @@ def formula(self, component_type: str, metric: str) -> str:
282265

283266
return formula
284267

268+
Schema: ClassVar[Type[Schema]] = Schema
269+
270+
@classmethod
271+
def _load_table_entries(cls, data: dict[str, Any]) -> dict[str, Self]:
272+
"""Load microgrid configurations from table entries.
273+
274+
Args:
275+
data: The loaded TOML data.
276+
277+
Returns:
278+
A dict mapping microgrid IDs to MicrogridConfig instances.
279+
280+
Raises:
281+
ValueError: If top-level keys are not numeric microgrid IDs
282+
or if there is a microgrid ID mismatch.
283+
TypeError: If microgrid data is not a dict.
284+
"""
285+
if not all(str(k).isdigit() for k in data.keys()):
286+
raise ValueError("All top-level keys must be numeric microgrid IDs.")
287+
288+
mgrids = {}
289+
for mid, entry in data.items():
290+
if not mid.isdigit():
291+
raise ValueError(
292+
f"Table reader: Microgrid ID key must be numeric, got {mid}"
293+
)
294+
if not isinstance(entry, dict):
295+
raise TypeError("Table reader: Each microgrid entry must be a dict")
296+
297+
mgrid = cls.Schema().load(entry)
298+
if mgrid.meta is None or mgrid.meta.microgrid_id is None:
299+
raise ValueError(
300+
"Table reader: Each microgrid entry must have a meta.microgrid_id"
301+
)
302+
if int(mgrid.meta.microgrid_id) != int(mid):
303+
raise ValueError(
304+
f"Table reader: Microgrid ID mismatch: key {mid} != {mgrid.meta.microgrid_id}"
305+
)
306+
307+
mgrids[mid] = mgrid
308+
309+
return mgrids
310+
311+
@classmethod
312+
def load_from_file(cls, config_path: Path) -> dict[int, Self]:
313+
"""
314+
Load and validate configuration settings from a TOML file.
315+
316+
Args:
317+
config_path: the path to the TOML configuration file.
318+
319+
Returns:
320+
A dict mapping microgrid IDs to MicrogridConfig instances.
321+
"""
322+
with config_path.open("rb") as f:
323+
data = tomllib.load(f)
324+
325+
assert isinstance(data, dict)
326+
327+
return cls._load_table_entries(data)
328+
285329
@staticmethod
286330
def load_configs(
287331
microgrid_config_files: str | Path | list[str | Path] | None = None,
@@ -340,14 +384,7 @@ def load_configs(
340384
_logger.warning("Config path %s is not a file, skipping.", config_path)
341385
continue
342386

343-
with config_path.open("rb") as f:
344-
cfg_dict = tomllib.load(f)
345-
for microgrid_id, mcfg in cfg_dict.items():
346-
_logger.debug(
347-
"Loading microgrid config for ID %s from %s",
348-
microgrid_id,
349-
config_path,
350-
)
351-
microgrid_configs[microgrid_id] = MicrogridConfig(mcfg)
387+
mcfgs = MicrogridConfig.load_from_file(config_path)
388+
microgrid_configs.update({str(key): value for key, value in mcfgs.items()})
352389

353390
return microgrid_configs

tests/test_config.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""Tests for the frequenz.lib.notebooks.config module."""
55

66
from pathlib import Path
7-
from typing import Any, cast
7+
from typing import Any
88

99
import pytest
1010
from pytest_mock import MockerFixture
@@ -41,7 +41,8 @@
4141
@pytest.fixture
4242
def valid_microgrid_config() -> MicrogridConfig:
4343
"""Fixture to provide a valid MicrogridConfig instance."""
44-
return MicrogridConfig(VALID_CONFIG["1"])
44+
# pylint: disable=protected-access
45+
return MicrogridConfig._load_table_entries(VALID_CONFIG)["1"]
4546

4647

4748
def test_is_valid_type() -> None:
@@ -64,10 +65,10 @@ def test_microgrid_config_init(valid_microgrid_config: MicrogridConfig) -> None:
6465
assert valid_microgrid_config.meta is not None
6566
assert valid_microgrid_config.meta.name == "Test Grid"
6667
pv_config = valid_microgrid_config.pv
67-
if pv_config:
68-
_assert_optional_field(
69-
cast(dict[str, float], pv_config["PV1"]).get("peak_power"), 5000
70-
)
68+
assert pv_config is not None
69+
pv_system = pv_config.get("PV1")
70+
assert pv_system is not None
71+
assert pv_system.peak_power == 5000
7172

7273

7374
def test_microgrid_config_component_types(
@@ -132,15 +133,18 @@ def test_load_configs(mocker: MockerFixture) -> None:
132133
assert "1" in configs
133134
assert configs["1"].meta is not None
134135
assert configs["1"].meta.name == "Test Grid"
136+
135137
pv_config = configs["1"].pv
138+
assert pv_config is not None
139+
pv_system = pv_config.get("PV1")
140+
assert pv_system is not None
141+
assert pv_system.peak_power == 5000
142+
136143
battery_config = configs["1"].battery
137-
if pv_config and battery_config:
138-
_assert_optional_field(
139-
cast(dict[str, float], pv_config["PV1"]).get("peak_power"), 5000
140-
)
141-
_assert_optional_field(
142-
cast(dict[str, float], battery_config["BAT1"]).get("capacity"), 10000
143-
)
144+
assert battery_config is not None
145+
battery_system = battery_config.get("BAT1")
146+
assert battery_system is not None
147+
assert battery_system.capacity == 10000
144148

145149

146150
def _assert_optional_field(value: float | None, expected: float) -> None:

0 commit comments

Comments
 (0)