Skip to content

Commit 67321be

Browse files
authored
Merge pull request #176 from cwasicki/cfg
Switch to schema-based microgrid config loading
2 parents f7e0f1e + 5889343 commit 67321be

File tree

4 files changed

+116
-101
lines changed

4 files changed

+116
-101
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
1010

11+
- `MicrogridConfig`: Switch to schema-based loading of microgrid config files and updates to the config class:
12+
- Remove unused nested field `assets` and replace by its contents `pv`, `wind`, `battery`.
13+
- Make `meta` and `ctype` public fields.
14+
- Require `meta.microgrid_id` to be set.
1115
- The minimum supported version of `matplotlib` is now `v3.9.2`.
1216
- Add `src/frequenz/lib/notebooks/reporting/schema_mapping.yaml` to your deployment so notebooks can load the canonical column definitions via `ColumnMapper`.
1317

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies = [
3535
"pvlib >= 0.13.0, < 0.14.0",
3636
"python-dotenv >= 0.21.0, < 1.2.0",
3737
"toml >= 0.10.2, < 0.11.0",
38+
"marshmallow_dataclass >= 8.7.1, < 9",
3839
"plotly >= 6.0.0, < 6.4.0",
3940
"kaleido >= 0.2.1, < 1.2.0",
4041
"frequenz-client-reporting >= 0.19.0, < 0.20.0",

src/frequenz/data/microgrid/config.py

Lines changed: 85 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
import logging
77
import re
88
import tomllib
9-
from dataclasses import dataclass
9+
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
12+
13+
from marshmallow import Schema
14+
from marshmallow_dataclass import dataclass
1215

1316
_logger = logging.getLogger(__name__)
1417

@@ -23,9 +26,6 @@
2326
class ComponentTypeConfig:
2427
"""Configuration of a microgrid component type."""
2528

26-
component_type: ComponentType
27-
"""Type of the component."""
28-
2929
meter: list[int] | None = None
3030
"""List of meter IDs for this component."""
3131

@@ -45,12 +45,6 @@ def __post_init__(self) -> None:
4545
self.formula["AC_ACTIVE_POWER"] = "+".join(
4646
[f"#{cid}" for cid in self._default_cids()]
4747
)
48-
if self.component_type == "battery" and "BATTERY_SOC_PCT" not in self.formula:
49-
if self.component:
50-
cids = self.component
51-
form = "+".join([f"#{cid}" for cid in cids])
52-
form = f"({form})/({len(cids)})"
53-
self.formula["BATTERY_SOC_PCT"] = form
5448

5549
def cids(self, metric: str = "") -> list[int]:
5650
"""Get component IDs for this component.
@@ -74,9 +68,7 @@ def cids(self, metric: str = "") -> list[int]:
7468
raise ValueError("Formula must be a dictionary.")
7569
formula = self.formula.get(metric)
7670
if not formula:
77-
raise ValueError(
78-
f"{metric} does not have a formula for {self.component_type}"
79-
)
71+
raise ValueError(f"{metric} does not have a formula")
8072
# Extract component IDs from the formula which are given as e.g. #123
8173
pattern = r"#(\d+)"
8274
return [int(e) for e in re.findall(pattern, self.formula[metric])]
@@ -102,7 +94,7 @@ def _default_cids(self) -> list[int]:
10294
if self.component:
10395
return self.component
10496

105-
raise ValueError(f"No IDs available for {self.component_type}")
97+
raise ValueError("No IDs available")
10698

10799
@classmethod
108100
def is_valid_type(cls, ctype: str) -> bool:
@@ -158,31 +150,17 @@ class BatteryConfig:
158150
"""Capacity of the battery in Wh."""
159151

160152

161-
@dataclass(frozen=True)
162-
class AssetsConfig:
163-
"""Configuration of the assets in a microgrid."""
164-
165-
pv: dict[str, PVConfig] | None = None
166-
"""Configuration of the PV system."""
167-
168-
wind: dict[str, WindConfig] | None = None
169-
"""Configuration of the wind turbines."""
170-
171-
battery: dict[str, BatteryConfig] | None = None
172-
"""Configuration of the batteries."""
173-
174-
175153
# pylint: disable=too-many-instance-attributes
176154
@dataclass(frozen=True)
177155
class Metadata:
178156
"""Metadata for a microgrid."""
179157

158+
microgrid_id: int
159+
"""ID of the microgrid."""
160+
180161
name: str | None = None
181162
"""Name of the microgrid."""
182163

183-
microgrid_id: int | None = None
184-
"""ID of the microgrid."""
185-
186164
enterprise_id: int | None = None
187165
"""Enterprise ID of the microgrid."""
188166

@@ -206,48 +184,24 @@ class Metadata:
206184
class MicrogridConfig:
207185
"""Configuration of a microgrid."""
208186

209-
_metadata: Metadata
187+
meta: Metadata
210188
"""Metadata of the microgrid."""
211189

212-
_assets_cfg: AssetsConfig
213-
"""Configuration of the assets in the microgrid."""
190+
pv: dict[str, PVConfig] | None = None
191+
"""Configuration of the PV system."""
214192

215-
_component_types_cfg: dict[str, ComponentTypeConfig]
216-
"""Mapping of component category types to ac power component config."""
193+
wind: dict[str, WindConfig] | None = None
194+
"""Configuration of the wind turbines."""
217195

218-
def __init__(self, config_dict: dict[str, Any]) -> None:
219-
"""Initialize the microgrid configuration.
196+
battery: dict[str, BatteryConfig] | None = None
197+
"""Configuration of the batteries."""
220198

221-
Args:
222-
config_dict: Dictionary with component type as key and config as value.
223-
"""
224-
self._metadata = Metadata(**(config_dict.get("meta") or {}))
225-
226-
self._assets_cfg = AssetsConfig(
227-
pv=config_dict.get("pv") or {},
228-
wind=config_dict.get("wind") or {},
229-
battery=config_dict.get("battery") or {},
230-
)
231-
232-
self._component_types_cfg = {
233-
ctype: ComponentTypeConfig(component_type=cast(ComponentType, ctype), **cfg)
234-
for ctype, cfg in config_dict.get("ctype", {}).items()
235-
if ComponentTypeConfig.is_valid_type(ctype)
236-
}
237-
238-
@property
239-
def meta(self) -> Metadata:
240-
"""Return the metadata of the microgrid."""
241-
return self._metadata
242-
243-
@property
244-
def assets(self) -> AssetsConfig:
245-
"""Return the assets configuration of the microgrid."""
246-
return self._assets_cfg
199+
ctype: dict[str, ComponentTypeConfig] = field(default_factory=dict)
200+
"""Mapping of component category types to ac power component config."""
247201

248202
def component_types(self) -> list[str]:
249203
"""Get a list of all component types in the configuration."""
250-
return list(self._component_types_cfg.keys())
204+
return list(self.ctype.keys())
251205

252206
def component_type_ids(
253207
self,
@@ -271,7 +225,7 @@ def component_type_ids(
271225
ValueError: If the component type is unknown.
272226
KeyError: If `component_category` is invalid.
273227
"""
274-
cfg = self._component_types_cfg.get(component_type)
228+
cfg = self.ctype.get(component_type)
275229
if not cfg:
276230
raise ValueError(f"{component_type} not found in config.")
277231

@@ -300,7 +254,7 @@ def formula(self, component_type: str, metric: str) -> str:
300254
Raises:
301255
ValueError: If the component type is unknown or formula is missing.
302256
"""
303-
cfg = self._component_types_cfg.get(component_type)
257+
cfg = self.ctype.get(component_type)
304258
if not cfg:
305259
raise ValueError(f"{component_type} not found in config.")
306260
if cfg.formula is None:
@@ -311,6 +265,67 @@ def formula(self, component_type: str, metric: str) -> str:
311265

312266
return formula
313267

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+
314329
@staticmethod
315330
def load_configs(
316331
microgrid_config_files: str | Path | list[str | Path] | None = None,
@@ -369,14 +384,7 @@ def load_configs(
369384
_logger.warning("Config path %s is not a file, skipping.", config_path)
370385
continue
371386

372-
with config_path.open("rb") as f:
373-
cfg_dict = tomllib.load(f)
374-
for microgrid_id, mcfg in cfg_dict.items():
375-
_logger.debug(
376-
"Loading microgrid config for ID %s from %s",
377-
microgrid_id,
378-
config_path,
379-
)
380-
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()})
381389

382390
return microgrid_configs

tests/test_config.py

Lines changed: 26 additions & 24 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
@@ -14,7 +14,7 @@
1414

1515
VALID_CONFIG: dict[str, dict[str, Any]] = {
1616
"1": {
17-
"meta": {"name": "Test Grid", "gid": 1},
17+
"meta": {"name": "Test Grid", "gid": 1, "microgrid_id": 1},
1818
"ctype": {
1919
"pv": {"meter": [101, 102], "formula": {"AC_ACTIVE_POWER": "#12+#23"}},
2020
"battery": {
@@ -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:
@@ -52,25 +53,22 @@ def test_is_valid_type() -> None:
5253

5354
def test_component_type_config_cids() -> None:
5455
"""Test the retrieval of component IDs for various configurations."""
55-
config = ComponentTypeConfig(component_type="pv", meter=[1, 2, 3])
56+
config = ComponentTypeConfig(inverter=[1, 2, 3])
5657
assert config.cids() == [1, 2, 3]
5758

58-
config = ComponentTypeConfig(component_type="battery", inverter=[4, 5])
59+
config = ComponentTypeConfig(meter=[4, 5], inverter=[1, 2, 3])
5960
assert config.cids() == [4, 5]
6061

61-
with pytest.raises(ValueError):
62-
config = ComponentTypeConfig(component_type="grid")
63-
config.cids()
64-
6562

6663
def test_microgrid_config_init(valid_microgrid_config: MicrogridConfig) -> None:
6764
"""Test initialisation of MicrogridConfig with valid configuration data."""
65+
assert valid_microgrid_config.meta is not None
6866
assert valid_microgrid_config.meta.name == "Test Grid"
69-
pv_config = valid_microgrid_config.assets.pv
70-
if pv_config:
71-
_assert_optional_field(
72-
cast(dict[str, float], pv_config["PV1"]).get("peak_power"), 5000
73-
)
67+
pv_config = valid_microgrid_config.pv
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
7472

7573

7674
def test_microgrid_config_component_types(
@@ -115,10 +113,10 @@ def test_microgrid_config_formula(valid_microgrid_config: MicrogridConfig) -> No
115113
def test_load_configs(mocker: MockerFixture) -> None:
116114
"""Test loading configurations for multiple microgrids from mock TOML files."""
117115
toml_data = """
116+
1.meta.microgrid_id = 1
118117
1.meta.name = "Test Grid"
119118
1.meta.gid = 1
120119
1.ctype.pv.meter = [101, 102]
121-
1.ctype.pv.formula = "AC_ACTIVE_POWER"
122120
1.ctype.battery.inverter = [201, 202, 203]
123121
1.ctype.battery.component = [301, 302, 303, 304, 305, 306]
124122
1.pv.PV1.peak_power = 5000
@@ -133,16 +131,20 @@ def test_load_configs(mocker: MockerFixture) -> None:
133131
configs = MicrogridConfig.load_configs(Path("mock_path.toml"))
134132

135133
assert "1" in configs
134+
assert configs["1"].meta is not None
136135
assert configs["1"].meta.name == "Test Grid"
137-
pv_config = configs["1"].assets.pv
138-
battery_config = configs["1"].assets.battery
139-
if pv_config and battery_config:
140-
_assert_optional_field(
141-
cast(dict[str, float], pv_config["PV1"]).get("peak_power"), 5000
142-
)
143-
_assert_optional_field(
144-
cast(dict[str, float], battery_config["BAT1"]).get("capacity"), 10000
145-
)
136+
137+
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+
143+
battery_config = configs["1"].battery
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
146148

147149

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

0 commit comments

Comments
 (0)