diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ebbf3037..37af0a04 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,12 +2,16 @@ ## Summary + ## Upgrading +* Switch to `MicrogridConfig` from `frequenz-gridpool` package. ## New Features + ## Bug Fixes -- Fixed wind components in the energy_report_df and aggregating metrics. + + diff --git a/pyproject.toml b/pyproject.toml index 940b30a4..c3ffacf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] requires-python = ">= 3.11, < 4" dependencies = [ - "typing-extensions >= 4.12.2, < 5", + "typing-extensions >= 4.14.1, < 5", "numpy >= 2.3.1, < 3", "pandas >= 2.3.1, < 3", "pyarrow >= 20.0.0, < 23.0.0", @@ -40,6 +40,7 @@ dependencies = [ "kaleido >= 0.2.1, < 1.3.0", "frequenz-client-reporting >= 0.19.0, < 0.20.0", "frequenz-client-weather >= 0.2.3, < 0.3.0", + "frequenz-gridpool >= 0.1, < 0.2", "types-pyyaml>=6.0.12.20250915", "marshmallow>=4.1.0,<5", "pyyaml>=6.0.3", diff --git a/src/frequenz/data/microgrid/__init__.py b/src/frequenz/data/microgrid/__init__.py index 97cba0e7..f324c28e 100644 --- a/src/frequenz/data/microgrid/__init__.py +++ b/src/frequenz/data/microgrid/__init__.py @@ -3,9 +3,10 @@ """Initialize the microgrid data module.""" +from frequenz.gridpool import MicrogridConfig + from ._stateful_data_fetcher import StatefulDataFetcher from .component_data import MicrogridData -from .config import MicrogridConfig __all__ = [ "MicrogridConfig", diff --git a/src/frequenz/data/microgrid/component_data.py b/src/frequenz/data/microgrid/component_data.py index 3584f520..f02a74be 100644 --- a/src/frequenz/data/microgrid/component_data.py +++ b/src/frequenz/data/microgrid/component_data.py @@ -10,8 +10,7 @@ import pandas as pd from frequenz.client.common.metric import Metric from frequenz.client.reporting import ReportingApiClient - -from .config import MicrogridConfig +from frequenz.gridpool import MicrogridConfig _logger = logging.getLogger(__name__) diff --git a/src/frequenz/data/microgrid/config.py b/src/frequenz/data/microgrid/config.py deleted file mode 100644 index 624a0bef..00000000 --- a/src/frequenz/data/microgrid/config.py +++ /dev/null @@ -1,390 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Configuration for microgrids.""" - -import logging -import re -import tomllib -from dataclasses import field -from pathlib import Path -from typing import Any, ClassVar, Literal, Self, Type, cast, get_args - -from marshmallow import Schema -from marshmallow_dataclass import dataclass - -_logger = logging.getLogger(__name__) - -ComponentType = Literal["grid", "pv", "battery", "consumption", "chp", "ev"] -"""Valid component types.""" - -ComponentCategory = Literal["meter", "inverter", "component"] -"""Valid component categories.""" - - -@dataclass -class ComponentTypeConfig: - """Configuration of a microgrid component type.""" - - meter: list[int] | None = None - """List of meter IDs for this component.""" - - inverter: list[int] | None = None - """List of inverter IDs for this component.""" - - component: list[int] | None = None - """List of component IDs for this component.""" - - formula: dict[str, str] | None = None - """Formula to calculate the power of this component.""" - - def __post_init__(self) -> None: - """Set the default formula if none is provided.""" - self.formula = self.formula or {} - if "AC_ACTIVE_POWER" not in self.formula: - self.formula["AC_ACTIVE_POWER"] = "+".join( - [f"#{cid}" for cid in self._default_cids()] - ) - - def cids(self, metric: str = "") -> list[int]: - """Get component IDs for this component. - - By default, the meter IDs are returned if available, otherwise the inverter IDs. - For components without meters or inverters, the component IDs are returned. - - If a metric is provided, the component IDs are extracted from the formula. - - Args: - metric: Metric name of the formula. - - Returns: - List of component IDs for this component. - - Raises: - ValueError: If the metric is not supported or improperly set. - """ - if metric: - if not isinstance(self.formula, dict): - raise ValueError("Formula must be a dictionary.") - formula = self.formula.get(metric) - if not formula: - raise ValueError(f"{metric} does not have a formula") - # Extract component IDs from the formula which are given as e.g. #123 - pattern = r"#(\d+)" - return [int(e) for e in re.findall(pattern, self.formula[metric])] - - return self._default_cids() - - def _default_cids(self) -> list[int]: - """Get the default component IDs for this component. - - If available, the meter IDs are returned, otherwise the inverter IDs. - For components without meters or inverters, the component IDs are returned. - - Returns: - List of component IDs for this component. - - Raises: - ValueError: If no IDs are available. - """ - if self.meter: - return self.meter - if self.inverter: - return self.inverter - if self.component: - return self.component - - raise ValueError("No IDs available") - - @classmethod - def is_valid_type(cls, ctype: str) -> bool: - """Check if `ctype` is a valid enum value.""" - return ctype in get_args(ComponentType) - - -@dataclass(frozen=True) -class PVConfig: - """Configuration of a PV system in a microgrid.""" - - peak_power: float | None = None - """Peak power of the PV system in Watt.""" - - rated_power: float | None = None - """Rated power of the inverters in Watt.""" - - curtailable: bool = False - """Flag to indicate if PV system can be curtailed.""" - - -@dataclass(frozen=True) -class WindConfig: - """Configuration of a wind turbine in a microgrid.""" - - turbine_model: str | None = None - """Model name of the wind turbine.""" - - rated_power: float | None = None - """Rated power of the wind turbine in Watt.""" - - turbine_height: float | None = None - """Height of the wind turbine in meters.""" - - number_of_turbines: int = 1 - """Number of wind turbines.""" - - hellmann_exponent: float | None = None - """Hellmann exponent for wind speed extrapolation. See: https://w.wiki/FMw9""" - - longitude: float | None = None - """Geographic longitude of the wind turbine.""" - - latitude: float | None = None - """Geographic latitude of the wind turbine.""" - - -@dataclass(frozen=True) -class BatteryConfig: - """Configuration of a battery in a microgrid.""" - - capacity: float | None = None - """Capacity of the battery in Wh.""" - - -# pylint: disable=too-many-instance-attributes -@dataclass(frozen=True) -class Metadata: - """Metadata for a microgrid.""" - - microgrid_id: int - """ID of the microgrid.""" - - name: str | None = None - """Name of the microgrid.""" - - enterprise_id: int | None = None - """Enterprise ID of the microgrid.""" - - gid: int | None = None - """Gridpool ID of the microgrid.""" - - delivery_area: str | None = None - """Delivery area of the microgrid.""" - - latitude: float | None = None - """Geographic latitude of the microgrid.""" - - longitude: float | None = None - """Geographic longitude of the microgrid.""" - - altitude: float | None = None - """Geographic altitude of the microgrid.""" - - -@dataclass -class MicrogridConfig: - """Configuration of a microgrid.""" - - meta: Metadata - """Metadata of the microgrid.""" - - pv: dict[str, PVConfig] | None = None - """Configuration of the PV system.""" - - wind: dict[str, WindConfig] | None = None - """Configuration of the wind turbines.""" - - battery: dict[str, BatteryConfig] | None = None - """Configuration of the batteries.""" - - ctype: dict[str, ComponentTypeConfig] = field(default_factory=dict) - """Mapping of component category types to ac power component config.""" - - def component_types(self) -> list[str]: - """Get a list of all component types in the configuration.""" - return list(self.ctype.keys()) - - def component_type_ids( - self, - component_type: str, - component_category: str | None = None, - metric: str = "", - ) -> list[int]: - """Get a list of all component IDs for a component type. - - Args: - component_type: Component type to be aggregated. - component_category: Specific category of component IDs to retrieve - (e.g., "meter", "inverter", or "component"). If not provided, - the default logic is used. - metric: Metric name of the formula if CIDs should be extracted from the formula. - - Returns: - List of component IDs for this component type. - - Raises: - ValueError: If the component type is unknown. - KeyError: If `component_category` is invalid. - """ - cfg = self.ctype.get(component_type) - if not cfg: - raise ValueError(f"{component_type} not found in config.") - - if component_category: - valid_categories = get_args(ComponentCategory) - if component_category not in valid_categories: - raise KeyError( - f"Invalid component category: {component_category}. " - f"Valid categories are {valid_categories}" - ) - category_ids = cast(list[int], getattr(cfg, component_category, [])) - return category_ids - - return cfg.cids(metric) - - def formula(self, component_type: str, metric: str) -> str: - """Get the formula for a component type. - - Args: - component_type: Component type to be aggregated. - metric: Metric to be aggregated. - - Returns: - Formula to be used for this aggregated component as string. - - Raises: - ValueError: If the component type is unknown or formula is missing. - """ - cfg = self.ctype.get(component_type) - if not cfg: - raise ValueError(f"{component_type} not found in config.") - if cfg.formula is None: - raise ValueError(f"No formula set for {component_type}") - formula = cfg.formula.get(metric) - if not formula: - raise ValueError(f"{component_type} is missing formula for {metric}") - - return formula - - Schema: ClassVar[Type[Schema]] = Schema - - @classmethod - def _load_table_entries(cls, data: dict[str, Any]) -> dict[str, Self]: - """Load microgrid configurations from table entries. - - Args: - data: The loaded TOML data. - - Returns: - A dict mapping microgrid IDs to MicrogridConfig instances. - - Raises: - ValueError: If top-level 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.") - - mgrids = {} - for mid, entry in data.items(): - if not mid.isdigit(): - raise ValueError( - f"Table reader: Microgrid ID key must be numeric, got {mid}" - ) - if not isinstance(entry, dict): - raise TypeError("Table reader: Each microgrid entry must be a dict") - - mgrid = cls.Schema().load(entry) - if mgrid.meta is None or mgrid.meta.microgrid_id is None: - raise ValueError( - "Table reader: Each microgrid entry must have a meta.microgrid_id" - ) - if int(mgrid.meta.microgrid_id) != int(mid): - raise ValueError( - f"Table reader: Microgrid ID mismatch: key {mid} != {mgrid.meta.microgrid_id}" - ) - - mgrids[mid] = mgrid - - return mgrids - - @classmethod - def load_from_file(cls, config_path: Path) -> dict[int, Self]: - """ - Load and validate configuration settings from a TOML file. - - Args: - config_path: the path to the TOML configuration file. - - Returns: - A dict mapping microgrid IDs to MicrogridConfig instances. - """ - with config_path.open("rb") as f: - data = tomllib.load(f) - - assert isinstance(data, dict) - - return cls._load_table_entries(data) - - @staticmethod - def load_configs( - 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 diff --git a/src/frequenz/lib/notebooks/reporting/data_processing.py b/src/frequenz/lib/notebooks/reporting/data_processing.py index 016cef41..06287364 100644 --- a/src/frequenz/lib/notebooks/reporting/data_processing.py +++ b/src/frequenz/lib/notebooks/reporting/data_processing.py @@ -23,8 +23,8 @@ """ import pandas as pd +from frequenz.gridpool import MicrogridConfig -from frequenz.data.microgrid.config import MicrogridConfig from frequenz.lib.notebooks.reporting.utils.column_mapper import ColumnMapper from frequenz.lib.notebooks.reporting.utils.helpers import ( add_energy_flows, diff --git a/src/frequenz/lib/notebooks/reporting/utils/helpers.py b/src/frequenz/lib/notebooks/reporting/utils/helpers.py index df261170..cae8c54a 100644 --- a/src/frequenz/lib/notebooks/reporting/utils/helpers.py +++ b/src/frequenz/lib/notebooks/reporting/utils/helpers.py @@ -46,8 +46,8 @@ import plotly.express as px import pytz import yaml +from frequenz.gridpool import MicrogridConfig -from frequenz.data.microgrid.config import MicrogridConfig from frequenz.lib.notebooks.reporting.metrics.reporting_metrics import ( asset_production, grid_consumption, diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index ec1be72b..00000000 --- a/tests/test_config.py +++ /dev/null @@ -1,162 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Tests for the frequenz.lib.notebooks.config module.""" - -from pathlib import Path -from typing import Any - -import pytest -from pytest_mock import MockerFixture - -from frequenz.data.microgrid import MicrogridConfig -from frequenz.data.microgrid.config import ComponentTypeConfig - -VALID_CONFIG: dict[str, dict[str, Any]] = { - "1": { - "meta": {"name": "Test Grid", "gid": 1, "microgrid_id": 1}, - "ctype": { - "pv": {"meter": [101, 102], "formula": {"AC_ACTIVE_POWER": "#12+#23"}}, - "battery": { - "inverter": [201, 202, 203], - "component": [301, 302, 303, 304, 305, 306], - }, - }, - "pv": { - "PV1": {"peak_power": 5000, "rated_power": 4500}, - "PV2": {"peak_power": 8000, "rated_power": 7000}, - }, - "battery": { - "BAT1": {"capacity": 10000}, - "BAT2": {"capacity": 12000}, - "BAT3": {"capacity": 8000}, - "BAT4": {"capacity": 15000}, - "BAT5": {"capacity": 20000}, - "BAT6": {}, - }, - }, -} - - -@pytest.fixture -def valid_microgrid_config() -> MicrogridConfig: - """Fixture to provide a valid MicrogridConfig instance.""" - # pylint: disable=protected-access - return MicrogridConfig._load_table_entries(VALID_CONFIG)["1"] - - -def test_is_valid_type() -> None: - """Test the validation of component types.""" - assert ComponentTypeConfig.is_valid_type("pv") - assert not ComponentTypeConfig.is_valid_type("unknown") - - -def test_component_type_config_cids() -> None: - """Test the retrieval of component IDs for various configurations.""" - config = ComponentTypeConfig(inverter=[1, 2, 3]) - assert config.cids() == [1, 2, 3] - - config = ComponentTypeConfig(meter=[4, 5], inverter=[1, 2, 3]) - assert config.cids() == [4, 5] - - -def test_microgrid_config_init(valid_microgrid_config: MicrogridConfig) -> None: - """Test initialisation of MicrogridConfig with valid configuration data.""" - assert valid_microgrid_config.meta is not None - assert valid_microgrid_config.meta.name == "Test Grid" - pv_config = valid_microgrid_config.pv - assert pv_config is not None - pv_system = pv_config.get("PV1") - assert pv_system is not None - assert pv_system.peak_power == 5000 - - -def test_microgrid_config_component_types( - valid_microgrid_config: MicrogridConfig, -) -> None: - """Test retrieval of all component types in the configuration.""" - assert valid_microgrid_config.component_types() == ["pv", "battery"] - - -def test_microgrid_config_component_type_ids( - valid_microgrid_config: MicrogridConfig, -) -> None: - """Test retrieval of component IDs for a given component type.""" - assert valid_microgrid_config.component_type_ids("pv") == [101, 102] - assert valid_microgrid_config.component_type_ids("battery") == [201, 202, 203] - assert valid_microgrid_config.component_type_ids("battery", "inverter") == [ - 201, - 202, - 203, - ] - assert valid_microgrid_config.component_type_ids("battery", "component") == [ - 301, - 302, - 303, - 304, - 305, - 306, - ] - - with pytest.raises(ValueError): - valid_microgrid_config.component_type_ids("unknown") - - -def test_microgrid_config_formula(valid_microgrid_config: MicrogridConfig) -> None: - """Test retrieval of formula for a given component type and metric.""" - assert valid_microgrid_config.formula("pv", "AC_ACTIVE_POWER") == "#12+#23" - - with pytest.raises(ValueError): - valid_microgrid_config.formula("pv", "INVALID_METRIC") - - -def test_load_configs(mocker: MockerFixture) -> None: - """Test loading configurations for multiple microgrids from mock TOML files.""" - toml_data = """ - 1.meta.microgrid_id = 1 - 1.meta.name = "Test Grid" - 1.meta.gid = 1 - 1.ctype.pv.meter = [101, 102] - 1.ctype.battery.inverter = [201, 202, 203] - 1.ctype.battery.component = [301, 302, 303, 304, 305, 306] - 1.pv.PV1.peak_power = 5000 - 1.pv.PV1.rated_power = 4500 - 1.pv.PV2.peak_power = 8000 - 1.pv.PV2.rated_power = 7000 - 1.battery.BAT1.capacity = 10000 - """ - 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")) - - assert "1" in configs - assert configs["1"].meta is not None - assert configs["1"].meta.name == "Test Grid" - - pv_config = configs["1"].pv - assert pv_config is not None - pv_system = pv_config.get("PV1") - assert pv_system is not None - assert pv_system.peak_power == 5000 - - battery_config = configs["1"].battery - assert battery_config is not None - battery_system = battery_config.get("BAT1") - assert battery_system is not None - assert battery_system.capacity == 10000 - - -def _assert_optional_field(value: float | None, expected: float) -> None: - """Validate an optional field. - - Args: - value: The optional field value to check. - expected: The expected value to assert if `value` is not None. - - Raises: - AssertionError: If `value` is not None and does not match `expected`. - """ - if value is not None: - if value != expected: - raise AssertionError(f"Expected {expected}, got {value}") diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f412237d..0939d050 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -12,9 +12,9 @@ import pandas as pd import pytest import pytz +from frequenz.gridpool import MicrogridConfig from pandas.testing import assert_frame_equal, assert_series_equal -from frequenz.data.microgrid.config import MicrogridConfig from frequenz.lib.notebooks.reporting.utils.helpers import ( _column_has_data, _get_numeric_series,