From b3bc0582a40e64eeee7a2d03a23c48f0d910b4f4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 23 May 2025 17:13:36 +0200 Subject: [PATCH 01/12] Add trivial subclasses of `Component` The following classes are added: * `Chp` * `Converter` * `CryptoMiner` * `Electrolyzer` * `Hvac` * `Meter` * `Precharger` * `Relay` All these components are just plain subclasses of `Component` that only override the category. Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 16 +++++++++ .../client/microgrid/component/_chp.py | 18 ++++++++++ .../client/microgrid/component/_converter.py | 18 ++++++++++ .../microgrid/component/_crypto_miner.py | 18 ++++++++++ .../microgrid/component/_electrolyzer.py | 18 ++++++++++ .../client/microgrid/component/_hvac.py | 18 ++++++++++ .../client/microgrid/component/_meter.py | 18 ++++++++++ .../client/microgrid/component/_precharger.py | 18 ++++++++++ .../client/microgrid/component/_relay.py | 18 ++++++++++ tests/component/test_chp.py | 32 +++++++++++++++++ tests/component/test_converter.py | 35 ++++++++++++++++++ tests/component/test_crypto_miner.py | 35 ++++++++++++++++++ tests/component/test_electrolyzer.py | 35 ++++++++++++++++++ tests/component/test_hvac.py | 31 ++++++++++++++++ tests/component/test_meter.py | 36 +++++++++++++++++++ tests/component/test_precharger.py | 36 +++++++++++++++++++ tests/component/test_relay.py | 36 +++++++++++++++++++ 17 files changed, 436 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_chp.py create mode 100644 src/frequenz/client/microgrid/component/_converter.py create mode 100644 src/frequenz/client/microgrid/component/_crypto_miner.py create mode 100644 src/frequenz/client/microgrid/component/_electrolyzer.py create mode 100644 src/frequenz/client/microgrid/component/_hvac.py create mode 100644 src/frequenz/client/microgrid/component/_meter.py create mode 100644 src/frequenz/client/microgrid/component/_precharger.py create mode 100644 src/frequenz/client/microgrid/component/_relay.py create mode 100644 tests/component/test_chp.py create mode 100644 tests/component/test_converter.py create mode 100644 tests/component/test_crypto_miner.py create mode 100644 tests/component/test_electrolyzer.py create mode 100644 tests/component/test_hvac.py create mode 100644 tests/component/test_meter.py create mode 100644 tests/component/test_precharger.py create mode 100644 tests/component/test_relay.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index 9543e938..ed86bee2 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -4,11 +4,27 @@ """All classes and functions related to microgrid components.""" from ._category import ComponentCategory +from ._chp import Chp from ._component import Component +from ._converter import Converter +from ._crypto_miner import CryptoMiner +from ._electrolyzer import Electrolyzer +from ._hvac import Hvac +from ._meter import Meter +from ._precharger import Precharger +from ._relay import Relay from ._status import ComponentStatus __all__ = [ + "Chp", "Component", "ComponentCategory", "ComponentStatus", + "Converter", + "CryptoMiner", + "Electrolyzer", + "Hvac", + "Meter", + "Precharger", + "Relay", ] diff --git a/src/frequenz/client/microgrid/component/_chp.py b/src/frequenz/client/microgrid/component/_chp.py new file mode 100644 index 00000000..0ae3aa75 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_chp.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""CHP component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Chp(Component): + """A combined heat and power (CHP) component.""" + + category: Literal[ComponentCategory.CHP] = ComponentCategory.CHP + """The category of this component.""" diff --git a/src/frequenz/client/microgrid/component/_converter.py b/src/frequenz/client/microgrid/component/_converter.py new file mode 100644 index 00000000..1751009b --- /dev/null +++ b/src/frequenz/client/microgrid/component/_converter.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Converter component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Converter(Component): + """An AC-DC converter component.""" + + category: Literal[ComponentCategory.CONVERTER] = ComponentCategory.CONVERTER + """The category of this component.""" diff --git a/src/frequenz/client/microgrid/component/_crypto_miner.py b/src/frequenz/client/microgrid/component/_crypto_miner.py new file mode 100644 index 00000000..893451d7 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_crypto_miner.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Crypto miner component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class CryptoMiner(Component): + """A crypto miner component.""" + + category: Literal[ComponentCategory.CRYPTO_MINER] = ComponentCategory.CRYPTO_MINER + """The category of this component.""" diff --git a/src/frequenz/client/microgrid/component/_electrolyzer.py b/src/frequenz/client/microgrid/component/_electrolyzer.py new file mode 100644 index 00000000..1d917262 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_electrolyzer.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Electrolyzer component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Electrolyzer(Component): + """An electrolyzer component.""" + + category: Literal[ComponentCategory.ELECTROLYZER] = ComponentCategory.ELECTROLYZER + """The category of this component.""" diff --git a/src/frequenz/client/microgrid/component/_hvac.py b/src/frequenz/client/microgrid/component/_hvac.py new file mode 100644 index 00000000..ed67a642 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_hvac.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""HVAC component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Hvac(Component): + """A heating, ventilation, and air conditioning (HVAC) component.""" + + category: Literal[ComponentCategory.HVAC] = ComponentCategory.HVAC + """The category of this component.""" diff --git a/src/frequenz/client/microgrid/component/_meter.py b/src/frequenz/client/microgrid/component/_meter.py new file mode 100644 index 00000000..51882fc9 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_meter.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Meter component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Meter(Component): + """A measuring meter component.""" + + category: Literal[ComponentCategory.METER] = ComponentCategory.METER + """The category of this component.""" diff --git a/src/frequenz/client/microgrid/component/_precharger.py b/src/frequenz/client/microgrid/component/_precharger.py new file mode 100644 index 00000000..cfff31e4 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_precharger.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Precharger component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Precharger(Component): + """A precharger component.""" + + category: Literal[ComponentCategory.PRECHARGER] = ComponentCategory.PRECHARGER + """The category of this component.""" diff --git a/src/frequenz/client/microgrid/component/_relay.py b/src/frequenz/client/microgrid/component/_relay.py new file mode 100644 index 00000000..bc0adc84 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_relay.py @@ -0,0 +1,18 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Relay component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Relay(Component): + """A relay component.""" + + category: Literal[ComponentCategory.RELAY] = ComponentCategory.RELAY + """The category of this component.""" diff --git a/tests/component/test_chp.py b/tests/component/test_chp.py new file mode 100644 index 00000000..c16ff5d5 --- /dev/null +++ b/tests/component/test_chp.py @@ -0,0 +1,32 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for CHP component.""" + + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import Chp, ComponentCategory, ComponentStatus + + +def test_init() -> None: + """Test CHP component initialization.""" + component_id = ComponentId(1) + microgrid_id = MicrogridId(1) + component = Chp( + id=component_id, + microgrid_id=microgrid_id, + name="chp_test", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "chp_test" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.CHP diff --git a/tests/component/test_converter.py b/tests/component/test_converter.py new file mode 100644 index 00000000..29a284ef --- /dev/null +++ b/tests/component/test_converter.py @@ -0,0 +1,35 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Converter component.""" + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + Converter, +) + + +def test_init() -> None: + """Test Converter component initialization.""" + component_id = ComponentId(1) + microgrid_id = MicrogridId(1) + component = Converter( + id=component_id, + microgrid_id=microgrid_id, + name="test_converter", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "test_converter" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.CONVERTER diff --git a/tests/component/test_crypto_miner.py b/tests/component/test_crypto_miner.py new file mode 100644 index 00000000..9a3a2bd6 --- /dev/null +++ b/tests/component/test_crypto_miner.py @@ -0,0 +1,35 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for CryptoMiner component.""" + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + CryptoMiner, +) + + +def test_init() -> None: + """Test CryptoMiner component initialization.""" + component_id = ComponentId(1) + microgrid_id = MicrogridId(1) + component = CryptoMiner( + id=component_id, + microgrid_id=microgrid_id, + name="test_crypto_miner", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "test_crypto_miner" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.CRYPTO_MINER diff --git a/tests/component/test_electrolyzer.py b/tests/component/test_electrolyzer.py new file mode 100644 index 00000000..61eeb1e5 --- /dev/null +++ b/tests/component/test_electrolyzer.py @@ -0,0 +1,35 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Electrolyzer component.""" + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + Electrolyzer, +) + + +def test_init() -> None: + """Test Electrolyzer component initialization.""" + component_id = ComponentId(1) + microgrid_id = MicrogridId(1) + component = Electrolyzer( + id=component_id, + microgrid_id=microgrid_id, + name="test_electrolyzer", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "test_electrolyzer" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.ELECTROLYZER diff --git a/tests/component/test_hvac.py b/tests/component/test_hvac.py new file mode 100644 index 00000000..ff398797 --- /dev/null +++ b/tests/component/test_hvac.py @@ -0,0 +1,31 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for HVAC component.""" + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ComponentCategory, ComponentStatus, Hvac + + +def test_init() -> None: + """Test HVAC component initialization.""" + component_id = ComponentId(1) + microgrid_id = MicrogridId(1) + component = Hvac( + id=component_id, + microgrid_id=microgrid_id, + name="test_hvac", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "test_hvac" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.HVAC diff --git a/tests/component/test_meter.py b/tests/component/test_meter.py new file mode 100644 index 00000000..80ed02f6 --- /dev/null +++ b/tests/component/test_meter.py @@ -0,0 +1,36 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Meter component.""" + + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + Meter, +) + + +def test_init() -> None: + """Test Meter component initialization.""" + component_id = ComponentId(1) + microgrid_id = MicrogridId(1) + component = Meter( + id=component_id, + microgrid_id=microgrid_id, + name="meter_test", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "meter_test" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.METER diff --git a/tests/component/test_precharger.py b/tests/component/test_precharger.py new file mode 100644 index 00000000..16edfd0d --- /dev/null +++ b/tests/component/test_precharger.py @@ -0,0 +1,36 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Precharger component.""" + + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + Precharger, +) + + +def test_init() -> None: + """Test Precharger component initialization.""" + component_id = ComponentId(1) + microgrid_id = MicrogridId(1) + component = Precharger( + id=component_id, + microgrid_id=microgrid_id, + name="precharger_test", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "precharger_test" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.PRECHARGER diff --git a/tests/component/test_relay.py b/tests/component/test_relay.py new file mode 100644 index 00000000..2ad82df8 --- /dev/null +++ b/tests/component/test_relay.py @@ -0,0 +1,36 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Relay component.""" + + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + Relay, +) + + +def test_init() -> None: + """Test Relay component initialization.""" + component_id = ComponentId(1) + microgrid_id = MicrogridId(1) + component = Relay( + id=component_id, + microgrid_id=microgrid_id, + name="relay_test", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "relay_test" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.RELAY From ff071aa2f8ee56f4198d403afd5ce0d766853a02 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 26 May 2025 12:26:19 +0200 Subject: [PATCH 02/12] Add `Fuse` component Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 2 + .../client/microgrid/component/_fuse.py | 39 +++++++++++ tests/component/test_fuse.py | 65 +++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_fuse.py create mode 100644 tests/component/test_fuse.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index ed86bee2..cbe0d3b3 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -9,6 +9,7 @@ from ._converter import Converter from ._crypto_miner import CryptoMiner from ._electrolyzer import Electrolyzer +from ._fuse import Fuse from ._hvac import Hvac from ._meter import Meter from ._precharger import Precharger @@ -23,6 +24,7 @@ "Converter", "CryptoMiner", "Electrolyzer", + "Fuse", "Hvac", "Meter", "Precharger", diff --git a/src/frequenz/client/microgrid/component/_fuse.py b/src/frequenz/client/microgrid/component/_fuse.py new file mode 100644 index 00000000..fc4455b1 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_fuse.py @@ -0,0 +1,39 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Fuse component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Fuse(Component): + """A fuse component. + + Fuses are used to protect components from overcurrents. + """ + + category: Literal[ComponentCategory.FUSE] = ComponentCategory.FUSE + """The category of this component.""" + + rated_current: int + """The rated current of the fuse in amperes. + + This is the maximum current that the fuse can withstand for a long time. This limit + applies to currents both flowing in or out of each of the 3 phases individually. + + In other words, a current `i`A at one of the phases of the node must comply with the + following constraint: + `-rated_fuse_current <= i <= rated_fuse_current` + """ + + def __post_init__(self) -> None: + """Validate the fuse's rated current.""" + if self.rated_current < 0: + raise ValueError( + f"rated_current must be a positive integer, not {self.rated_current}" + ) diff --git a/tests/component/test_fuse.py b/tests/component/test_fuse.py new file mode 100644 index 00000000..e1ae2cd5 --- /dev/null +++ b/tests/component/test_fuse.py @@ -0,0 +1,65 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Fuse component.""" + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ComponentCategory, ComponentStatus, Fuse + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return ComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +@pytest.mark.parametrize("rated_current", [0, 50]) +def test_creation_ok( + component_id: ComponentId, microgrid_id: MicrogridId, rated_current: int +) -> None: + """Test Fuse component initialization with different rated currents.""" + fuse = Fuse( + id=component_id, + microgrid_id=microgrid_id, + name="test_fuse", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + rated_current=rated_current, + ) + + assert fuse.id == component_id + assert fuse.microgrid_id == microgrid_id + assert fuse.name == "test_fuse" + assert fuse.manufacturer == "test_manufacturer" + assert fuse.model_name == "test_model" + assert fuse.status == ComponentStatus.ACTIVE + assert fuse.category == ComponentCategory.FUSE + assert fuse.rated_current == rated_current + + +def test_creation_invalid_rated_current( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test Fuse component initialization with invalid rated current.""" + with pytest.raises( + ValueError, match="rated_current must be a positive integer, not -1" + ): + Fuse( + id=component_id, + microgrid_id=microgrid_id, + name="test_fuse", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + rated_current=-1, + ) From b20c6c91a0c0995a7411a7bc0e6a285bb1e5c073 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 26 May 2025 12:33:41 +0200 Subject: [PATCH 03/12] Add `GridConnectionPoint` component Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 2 + .../component/_grid_connection_point.py | 59 ++++++++++++++++ tests/component/test_grid_connection_point.py | 69 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_grid_connection_point.py create mode 100644 tests/component/test_grid_connection_point.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index cbe0d3b3..4dd461fe 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -10,6 +10,7 @@ from ._crypto_miner import CryptoMiner from ._electrolyzer import Electrolyzer from ._fuse import Fuse +from ._grid_connection_point import GridConnectionPoint from ._hvac import Hvac from ._meter import Meter from ._precharger import Precharger @@ -25,6 +26,7 @@ "CryptoMiner", "Electrolyzer", "Fuse", + "GridConnectionPoint", "Hvac", "Meter", "Precharger", diff --git a/src/frequenz/client/microgrid/component/_grid_connection_point.py b/src/frequenz/client/microgrid/component/_grid_connection_point.py new file mode 100644 index 00000000..24c72c0c --- /dev/null +++ b/src/frequenz/client/microgrid/component/_grid_connection_point.py @@ -0,0 +1,59 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Grid connection point component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class GridConnectionPoint(Component): + """A point where a microgrid connects to the grid. + + The terms "Grid Connection Point" and "Point of Common Coupling" (PCC) are + commonly used in the context. + + While both terms describe a connection point to the grid, the + `GridConnectionPoint` is specifically the physical connection point of the + generation facility to the grid, often concerned with the technical and + ownership aspects of the connection. + + In contrast, the PCC is is more specific in terms of electrical engineering. + It refers to the point where a customer's local electrical system (such as a + microgrid) connects to the utility distribution grid in such a way that it + can affect other customers’ systems connected to the same network. It is the + point where the grid and customer's electrical systems interface and where + issues like power quality and supply regulations are assessed. + + The term `GridConnectionPoint` is used to make it clear that what is referred + to here is the physical connection point of the local facility to the grid. + Note that this may also be the PCC in some cases. + """ + + category: Literal[ComponentCategory.GRID] = ComponentCategory.GRID + """The category of this component.""" + + rated_fuse_current: int + """The maximum amount of electrical current that can flow through this connection, in amperes. + + The rated maximum amount of current the fuse at the grid connection point is + designed to safely carry under normal operating conditions. + + This limit applies to currents both flowing in or out of each of the 3 + phases individually. + + In other words, a current `i`A at one of the phases of the grid connection + point must comply with the following constraint: + `-rated_fuse_current <= i <= rated_fuse_current` + """ + + def __post_init__(self) -> None: + """Validate the fuse's rated current.""" + if self.rated_fuse_current < 0: + raise ValueError( + f"rated_fuse_current must be a positive integer, not {self.rated_fuse_current}" + ) diff --git a/tests/component/test_grid_connection_point.py b/tests/component/test_grid_connection_point.py new file mode 100644 index 00000000..3f102f10 --- /dev/null +++ b/tests/component/test_grid_connection_point.py @@ -0,0 +1,69 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for GridConnectionPoint component.""" + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + GridConnectionPoint, +) + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return ComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +@pytest.mark.parametrize("rated_fuse_current", [0, 50]) +def test_creation_ok( + component_id: ComponentId, microgrid_id: MicrogridId, rated_fuse_current: int +) -> None: + """Test GridConnectionPoint initialization with different rated fuse currents.""" + grid_point = GridConnectionPoint( + id=component_id, + microgrid_id=microgrid_id, + name="test_grid_point", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + rated_fuse_current=rated_fuse_current, + ) + + assert grid_point.id == component_id + assert grid_point.microgrid_id == microgrid_id + assert grid_point.name == "test_grid_point" + assert grid_point.manufacturer == "test_manufacturer" + assert grid_point.model_name == "test_model" + assert grid_point.status == ComponentStatus.ACTIVE + assert grid_point.category == ComponentCategory.GRID + assert grid_point.rated_fuse_current == rated_fuse_current + + +def test_creation_invalid_rated_fuse_current( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test Fuse component initialization with invalid rated current.""" + with pytest.raises( + ValueError, match="rated_fuse_current must be a positive integer, not -1" + ): + GridConnectionPoint( + id=component_id, + microgrid_id=microgrid_id, + name="test_grid_point", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + rated_fuse_current=-1, + ) From 052d90e0b86c281df6006f6cc82960ea5f771152 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 26 May 2025 12:39:19 +0200 Subject: [PATCH 04/12] Add `VoltageTransformer` component Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 2 + .../component/_voltage_transformer.py | 43 ++++++++++++++ tests/component/test_voltage_transformer.py | 58 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_voltage_transformer.py create mode 100644 tests/component/test_voltage_transformer.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index 4dd461fe..de68cb51 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -16,6 +16,7 @@ from ._precharger import Precharger from ._relay import Relay from ._status import ComponentStatus +from ._voltage_transformer import VoltageTransformer __all__ = [ "Chp", @@ -31,4 +32,5 @@ "Meter", "Precharger", "Relay", + "VoltageTransformer", ] diff --git a/src/frequenz/client/microgrid/component/_voltage_transformer.py b/src/frequenz/client/microgrid/component/_voltage_transformer.py new file mode 100644 index 00000000..af9f367f --- /dev/null +++ b/src/frequenz/client/microgrid/component/_voltage_transformer.py @@ -0,0 +1,43 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Voltage transformer component.""" + +import dataclasses +from typing import Literal + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class VoltageTransformer(Component): + """A voltage transformer component. + + Voltage transformers are used to step up or step down the voltage, keeping + the power somewhat constant by increasing or decreasing the current. + + If voltage is stepped up, current is stepped down, and vice versa. + + Note: + Voltage transformers have efficiency losses, so the output power is always less + than the input power. + """ + + category: Literal[ComponentCategory.VOLTAGE_TRANSFORMER] = ( + ComponentCategory.VOLTAGE_TRANSFORMER + ) + """The category of this component.""" + + primary_voltage: float + """The primary voltage of the transformer, in volts. + + This is the input voltage that is stepped up or down. + """ + + secondary_voltage: float + """The secondary voltage of the transformer, in volts. + + This is the output voltage that is the result of stepping the primary + voltage up or down. + """ diff --git a/tests/component/test_voltage_transformer.py b/tests/component/test_voltage_transformer.py new file mode 100644 index 00000000..ca70a481 --- /dev/null +++ b/tests/component/test_voltage_transformer.py @@ -0,0 +1,58 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for VoltageTransformer component.""" + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + VoltageTransformer, +) + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return ComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +@pytest.mark.parametrize( + "primary, secondary", [(400.0, 230.0), (0.0, 0.0), (230.0, 400.0), (-230.0, -400.0)] +) +def test_creation_ok( + component_id: ComponentId, + microgrid_id: MicrogridId, + primary: float, + secondary: float, +) -> None: + """Test VoltageTransformer component initialization with different voltages.""" + voltage_transformer = VoltageTransformer( + id=component_id, + microgrid_id=microgrid_id, + name="test_voltage_transformer", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + primary_voltage=primary, + secondary_voltage=secondary, + ) + + assert voltage_transformer.id == component_id + assert voltage_transformer.microgrid_id == microgrid_id + assert voltage_transformer.name == "test_voltage_transformer" + assert voltage_transformer.manufacturer == "test_manufacturer" + assert voltage_transformer.model_name == "test_model" + assert voltage_transformer.status == ComponentStatus.ACTIVE + assert voltage_transformer.category == ComponentCategory.VOLTAGE_TRANSFORMER + assert voltage_transformer.primary_voltage == pytest.approx(primary) + assert voltage_transformer.secondary_voltage == pytest.approx(secondary) From 5a8f8bafb0572b5b7d2d31ab0ff66546510cf79c Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 26 May 2025 12:47:26 +0200 Subject: [PATCH 05/12] Add `Battery` component and sub-classes/types Battery components have an attached battery type. To encode this into the Python type system, we create a sub-class per each battery type, similar to what we do with components. The special types `UnspecifiedBattery` and `UnrecognizedBattery` are added to represent a battery with type `UNSPECIFIED` and a battery with a battery type we don't recognize (this could happen if using a newer server providing new battery types) respectively. On top of that, we define a `BatteryTypes` type alias to make it easy to type-hint function that want to return all known battery types as a type union instead of using inheritance. Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 16 +++ .../client/microgrid/component/_battery.py | 129 ++++++++++++++++++ tests/component/test_battery.py | 122 +++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_battery.py create mode 100644 tests/component/test_battery.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index de68cb51..11489383 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -3,6 +3,15 @@ """All classes and functions related to microgrid components.""" +from ._battery import ( + Battery, + BatteryType, + BatteryTypes, + LiIonBattery, + NaIonBattery, + UnrecognizedBattery, + UnspecifiedBattery, +) from ._category import ComponentCategory from ._chp import Chp from ._component import Component @@ -19,6 +28,9 @@ from ._voltage_transformer import VoltageTransformer __all__ = [ + "Battery", + "BatteryType", + "BatteryTypes", "Chp", "Component", "ComponentCategory", @@ -29,8 +41,12 @@ "Fuse", "GridConnectionPoint", "Hvac", + "LiIonBattery", "Meter", + "NaIonBattery", "Precharger", "Relay", + "UnrecognizedBattery", + "UnspecifiedBattery", "VoltageTransformer", ] diff --git a/src/frequenz/client/microgrid/component/_battery.py b/src/frequenz/client/microgrid/component/_battery.py new file mode 100644 index 00000000..d6f4bf1a --- /dev/null +++ b/src/frequenz/client/microgrid/component/_battery.py @@ -0,0 +1,129 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Battery component.""" + +import dataclasses +import enum +from typing import Any, Literal, Self, TypeAlias + +from frequenz.api.common.v1.microgrid.components import battery_pb2 + +from ._category import ComponentCategory +from ._component import Component + + +@enum.unique +class BatteryType(enum.Enum): + """The known types of batteries.""" + + UNSPECIFIED = battery_pb2.BATTERY_TYPE_UNSPECIFIED + """The battery type is unspecified.""" + + LI_ION = battery_pb2.BATTERY_TYPE_LI_ION + """Lithium-ion (Li-ion) battery.""" + + NA_ION = battery_pb2.BATTERY_TYPE_NA_ION + """Sodium-ion (Na-ion) battery.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Battery(Component): + """An abstract battery component.""" + + category: Literal[ComponentCategory.BATTERY] = ComponentCategory.BATTERY + """The category of this component. + + Note: + This should not be used normally, you should test if a component + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about a new category yet (i.e. for use with + [`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent]) + and in case some low level code needs to know the category of a component. + """ + + type: BatteryType | int + """The type of this battery. + + Note: + This should not be used normally, you should test if a battery + [`isinstance`][] of a concrete battery class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new battery type yet (i.e. for use with + [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + """ + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is Battery: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnspecifiedBattery(Battery): + """A battery of a unspecified type.""" + + type: Literal[BatteryType.UNSPECIFIED] = BatteryType.UNSPECIFIED + """The type of this battery. + + Note: + This should not be used normally, you should test if a battery + [`isinstance`][] of a concrete battery class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new battery type yet (i.e. for use with + [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class LiIonBattery(Battery): + """A Li-ion battery.""" + + type: Literal[BatteryType.LI_ION] = BatteryType.LI_ION + """The type of this battery. + + Note: + This should not be used normally, you should test if a battery + [`isinstance`][] of a concrete battery class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new battery type yet (i.e. for use with + [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class NaIonBattery(Battery): + """A Na-ion battery.""" + + type: Literal[BatteryType.NA_ION] = BatteryType.NA_ION + """The type of this battery. + + Note: + This should not be used normally, you should test if a battery + [`isinstance`][] of a concrete battery class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new battery type yet (i.e. for use with + [`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnrecognizedBattery(Battery): + """A battery of an unrecognized type.""" + + type: int + """The unrecognized type of this battery.""" + + +BatteryTypes: TypeAlias = ( + LiIonBattery | NaIonBattery | UnrecognizedBattery | UnspecifiedBattery +) +"""All possible battery types.""" diff --git a/tests/component/test_battery.py b/tests/component/test_battery.py new file mode 100644 index 00000000..828aa9bf --- /dev/null +++ b/tests/component/test_battery.py @@ -0,0 +1,122 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Battery components.""" + +import dataclasses + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + Battery, + BatteryType, + ComponentCategory, + ComponentStatus, + LiIonBattery, + NaIonBattery, + UnrecognizedBattery, + UnspecifiedBattery, +) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class BatteryTestCase: + """Test case for battery components.""" + + cls: type[UnspecifiedBattery | LiIonBattery | NaIonBattery] + expected_type: BatteryType + name: str + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return ComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +def test_abstract_battery_cannot_be_instantiated( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test that Battery base class cannot be instantiated.""" + with pytest.raises(TypeError, match="Cannot instantiate Battery directly"): + Battery( + id=component_id, + microgrid_id=microgrid_id, + name="test_battery", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + type=BatteryType.LI_ION, + ) + + +@pytest.mark.parametrize( + "case", + [ + BatteryTestCase( + cls=UnspecifiedBattery, + expected_type=BatteryType.UNSPECIFIED, + name="unspecified", + ), + BatteryTestCase( + cls=LiIonBattery, expected_type=BatteryType.LI_ION, name="li_ion" + ), + BatteryTestCase( + cls=NaIonBattery, expected_type=BatteryType.NA_ION, name="na_ion" + ), + ], + ids=lambda case: case.name, +) +def test_recognized_battery_types( + case: BatteryTestCase, component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of different battery types.""" + battery = case.cls( + id=component_id, + microgrid_id=microgrid_id, + name=case.name, + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert battery.id == component_id + assert battery.microgrid_id == microgrid_id + assert battery.name == case.name + assert battery.manufacturer == "test_manufacturer" + assert battery.model_name == "test_model" + assert battery.status == ComponentStatus.ACTIVE + assert battery.category == ComponentCategory.BATTERY + assert battery.type == case.expected_type + + +def test_unrecognized_battery_type( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of different battery types.""" + battery = UnrecognizedBattery( + id=component_id, + microgrid_id=microgrid_id, + name="unrecognized_battery", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + type=999, + ) + + assert battery.id == component_id + assert battery.microgrid_id == microgrid_id + assert battery.name == "unrecognized_battery" + assert battery.manufacturer == "test_manufacturer" + assert battery.model_name == "test_model" + assert battery.status == ComponentStatus.ACTIVE + assert battery.category == ComponentCategory.BATTERY + assert battery.type == 999 From eecd8c4f41005cc2fcf0d346e8c47377edeee1e9 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 26 May 2025 13:04:28 +0200 Subject: [PATCH 06/12] Add `EvCharger` component and sub-classes/types Like batteries, EV charger components have an attached type. To encode this into the Python type system, we create a sub-class per each type. As with batteries, special types are using to represent a EV charger with type `UNSPECIFIED` and with type we don't recognize, and define a type alias to make it easy to type-hint function that want to return all known EV charger types as a type union instead of using inheritance. Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 18 +++ .../client/microgrid/component/_ev_charger.py | 153 ++++++++++++++++++ tests/component/test_ev_charger.py | 124 ++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_ev_charger.py create mode 100644 tests/component/test_ev_charger.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index 11489383..8b10b3f2 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -18,6 +18,16 @@ from ._converter import Converter from ._crypto_miner import CryptoMiner from ._electrolyzer import Electrolyzer +from ._ev_charger import ( + AcEvCharger, + DcEvCharger, + EvCharger, + EvChargerType, + EvChargerTypes, + HybridEvCharger, + UnrecognizedEvCharger, + UnspecifiedEvCharger, +) from ._fuse import Fuse from ._grid_connection_point import GridConnectionPoint from ._hvac import Hvac @@ -28,6 +38,7 @@ from ._voltage_transformer import VoltageTransformer __all__ = [ + "AcEvCharger", "Battery", "BatteryType", "BatteryTypes", @@ -37,16 +48,23 @@ "ComponentStatus", "Converter", "CryptoMiner", + "DcEvCharger", "Electrolyzer", + "EvCharger", + "EvChargerType", + "EvChargerTypes", "Fuse", "GridConnectionPoint", "Hvac", + "HybridEvCharger", "LiIonBattery", "Meter", "NaIonBattery", "Precharger", "Relay", "UnrecognizedBattery", + "UnrecognizedEvCharger", "UnspecifiedBattery", + "UnspecifiedEvCharger", "VoltageTransformer", ] diff --git a/src/frequenz/client/microgrid/component/_ev_charger.py b/src/frequenz/client/microgrid/component/_ev_charger.py new file mode 100644 index 00000000..8e1605e7 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_ev_charger.py @@ -0,0 +1,153 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Electric vehicle (EV) charger component.""" + +import dataclasses +import enum +from typing import Any, Literal, Self, TypeAlias + +from frequenz.api.common.v1.microgrid.components import ev_charger_pb2 + +from ._category import ComponentCategory +from ._component import Component + + +@enum.unique +class EvChargerType(enum.Enum): + """The known types of electric vehicle (EV) chargers.""" + + UNSPECIFIED = ev_charger_pb2.EV_CHARGER_TYPE_UNSPECIFIED + """The type of the EV charger is unspecified.""" + + AC = ev_charger_pb2.EV_CHARGER_TYPE_AC + """The EV charging station supports AC charging only.""" + + DC = ev_charger_pb2.EV_CHARGER_TYPE_DC + """The EV charging station supports DC charging only.""" + + HYBRID = ev_charger_pb2.EV_CHARGER_TYPE_HYBRID + """The EV charging station supports both AC and DC.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class EvCharger(Component): + """An abstract EV charger component.""" + + category: Literal[ComponentCategory.EV_CHARGER] = ComponentCategory.EV_CHARGER + """The category of this component. + + Note: + This should not be used normally, you should test if a component + [`isinstance`][] of a concrete EV charger class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about a new category yet (i.e. for use with + [`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent]) + and in case some low level code needs to know the category of a component. + """ + + type: EvChargerType | int + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]). + """ + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is EvCharger: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnspecifiedEvCharger(EvCharger): + """An EV charger of an unspecified type.""" + + type: Literal[EvChargerType.UNSPECIFIED] = EvChargerType.UNSPECIFIED + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class AcEvCharger(EvCharger): + """An EV charger that supports AC charging only.""" + + type: Literal[EvChargerType.AC] = EvChargerType.AC + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class DcEvCharger(EvCharger): + """An EV charger that supports DC charging only.""" + + type: Literal[EvChargerType.DC] = EvChargerType.DC + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class HybridEvCharger(EvCharger): + """An EV charger that supports both AC and DC charging.""" + + type: Literal[EvChargerType.HYBRID] = EvChargerType.HYBRID + """The type of this EV charger. + + Note: + This should not be used normally, you should test if a EV charger + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new EV charger type yet (i.e. for use with + [`UnrecognizedEvCharger`][frequenz.client.microgrid.component.UnrecognizedEvCharger]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnrecognizedEvCharger(EvCharger): + """An EV charger of an unrecognized type.""" + + type: int + """The unrecognized type of this EV charger.""" + + +EvChargerTypes: TypeAlias = ( + UnspecifiedEvCharger + | AcEvCharger + | DcEvCharger + | HybridEvCharger + | UnrecognizedEvCharger +) +"""All possible EV charger types.""" diff --git a/tests/component/test_ev_charger.py b/tests/component/test_ev_charger.py new file mode 100644 index 00000000..5b9eaf40 --- /dev/null +++ b/tests/component/test_ev_charger.py @@ -0,0 +1,124 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for EV charger components.""" + +import dataclasses + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + AcEvCharger, + ComponentCategory, + ComponentStatus, + DcEvCharger, + EvCharger, + EvChargerType, + HybridEvCharger, + UnrecognizedEvCharger, + UnspecifiedEvCharger, +) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class EvChargerTestCase: + """Test case for EV charger components.""" + + cls: type[UnspecifiedEvCharger | AcEvCharger | DcEvCharger | HybridEvCharger] + expected_type: EvChargerType + name: str + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return ComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +def test_abstract_ev_charger_cannot_be_instantiated( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test that EvCharger base class cannot be instantiated.""" + with pytest.raises(TypeError, match="Cannot instantiate EvCharger directly"): + EvCharger( + id=component_id, + microgrid_id=microgrid_id, + name="test_charger", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + type=EvChargerType.AC, + ) + + +@pytest.mark.parametrize( + "case", + [ + EvChargerTestCase( + cls=UnspecifiedEvCharger, + expected_type=EvChargerType.UNSPECIFIED, + name="unspecified", + ), + EvChargerTestCase(cls=AcEvCharger, expected_type=EvChargerType.AC, name="ac"), + EvChargerTestCase(cls=DcEvCharger, expected_type=EvChargerType.DC, name="dc"), + EvChargerTestCase( + cls=HybridEvCharger, + expected_type=EvChargerType.HYBRID, + name="hybrid", + ), + ], + ids=lambda case: case.name, +) +def test_recognized_ev_charger_types( # Renamed from test_ev_charger_types + case: EvChargerTestCase, component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of different recognized EV charger types.""" + charger = case.cls( + id=component_id, + microgrid_id=microgrid_id, + name=case.name, + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert charger.id == component_id + assert charger.microgrid_id == microgrid_id + assert charger.name == case.name + assert charger.manufacturer == "test_manufacturer" + assert charger.model_name == "test_model" + assert charger.status == ComponentStatus.ACTIVE + assert charger.category == ComponentCategory.EV_CHARGER + assert charger.type == case.expected_type + + +def test_unrecognized_ev_charger_type( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of unrecognized EV charger type.""" + charger = UnrecognizedEvCharger( + id=component_id, + microgrid_id=microgrid_id, + name="unrecognized_charger", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + type=999, # type is passed here for UnrecognizedEvCharger + ) + + assert charger.id == component_id + assert charger.microgrid_id == microgrid_id + assert charger.name == "unrecognized_charger" + assert charger.manufacturer == "test_manufacturer" + assert charger.model_name == "test_model" + assert charger.status == ComponentStatus.ACTIVE + assert charger.category == ComponentCategory.EV_CHARGER + assert charger.type == 999 From 76f4c346fb9c50fd75bd23c25243ceb6102b6ecd Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 26 May 2025 14:12:10 +0200 Subject: [PATCH 07/12] Add `Inverter` component and sub-classes/types Like batteries and EV chargers, inverter components have an attached type. To encode this into the Python type system, we create a sub-class per each type. Special types are also used to represent a inverter with type `UNSPECIFIED` and with type we don't recognize, and we define a type alias to make it easy to type-hint function that want to return all known inverter types as a type union instead of using inheritance. Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 16 ++ .../client/microgrid/component/_inverter.py | 153 ++++++++++++++++++ tests/component/test_inverter.py | 126 +++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_inverter.py create mode 100644 tests/component/test_inverter.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index 8b10b3f2..ae2cc828 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -31,6 +31,15 @@ from ._fuse import Fuse from ._grid_connection_point import GridConnectionPoint from ._hvac import Hvac +from ._inverter import ( + BatteryInverter, + HybridInverter, + Inverter, + InverterType, + SolarInverter, + UnrecognizedInverter, + UnspecifiedInverter, +) from ._meter import Meter from ._precharger import Precharger from ._relay import Relay @@ -40,6 +49,7 @@ __all__ = [ "AcEvCharger", "Battery", + "BatteryInverter", "BatteryType", "BatteryTypes", "Chp", @@ -57,14 +67,20 @@ "GridConnectionPoint", "Hvac", "HybridEvCharger", + "HybridInverter", + "Inverter", + "InverterType", "LiIonBattery", "Meter", "NaIonBattery", "Precharger", "Relay", + "SolarInverter", "UnrecognizedBattery", "UnrecognizedEvCharger", + "UnrecognizedInverter", "UnspecifiedBattery", "UnspecifiedEvCharger", + "UnspecifiedInverter", "VoltageTransformer", ] diff --git a/src/frequenz/client/microgrid/component/_inverter.py b/src/frequenz/client/microgrid/component/_inverter.py new file mode 100644 index 00000000..8f7001c6 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_inverter.py @@ -0,0 +1,153 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Inverter component.""" + +import dataclasses +import enum +from typing import Any, Literal, Self, TypeAlias + +from frequenz.api.common.v1.microgrid.components import inverter_pb2 + +from ._category import ComponentCategory +from ._component import Component + + +@enum.unique +class InverterType(enum.Enum): + """The known types of inverters.""" + + UNSPECIFIED = inverter_pb2.INVERTER_TYPE_UNSPECIFIED + """The type of the inverter is unspecified.""" + + BATTERY = inverter_pb2.INVERTER_TYPE_BATTERY + """The inverter is a battery inverter.""" + + SOLAR = inverter_pb2.INVERTER_TYPE_SOLAR + """The inverter is a solar inverter.""" + + HYBRID = inverter_pb2.INVERTER_TYPE_HYBRID + """The inverter is a hybrid inverter.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Inverter(Component): + """An abstract inverter component.""" + + category: Literal[ComponentCategory.INVERTER] = ComponentCategory.INVERTER + """The category of this component. + + Note: + This should not be used normally, you should test if a component + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about a new category yet (i.e. for use with + [`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent]) + and in case some low level code needs to know the category of a component. + """ + + type: InverterType | int + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]). + """ + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is Inverter: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnspecifiedInverter(Inverter): + """An inverter of an unspecified type.""" + + type: Literal[InverterType.UNSPECIFIED] = InverterType.UNSPECIFIED + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class BatteryInverter(Inverter): + """A battery inverter.""" + + type: Literal[InverterType.BATTERY] = InverterType.BATTERY + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class SolarInverter(Inverter): + """A solar inverter.""" + + type: Literal[InverterType.SOLAR] = InverterType.SOLAR + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class HybridInverter(Inverter): + """A hybrid inverter.""" + + type: Literal[InverterType.HYBRID] = InverterType.HYBRID + """The type of this inverter. + + Note: + This should not be used normally, you should test if a inverter + [`isinstance`][] of a concrete inverter class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about the new inverter type yet (i.e. for use with + [`UnrecognizedInverter`][frequenz.client.microgrid.component.UnrecognizedInverter]). + """ + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnrecognizedInverter(Inverter): + """An inverter component.""" + + type: int + """The unrecognized type of this inverter.""" + + +InverterTypes: TypeAlias = ( + UnspecifiedInverter + | BatteryInverter + | SolarInverter + | HybridInverter + | UnrecognizedInverter +) +"""All possible inverter types.""" diff --git a/tests/component/test_inverter.py b/tests/component/test_inverter.py new file mode 100644 index 00000000..c598ce42 --- /dev/null +++ b/tests/component/test_inverter.py @@ -0,0 +1,126 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Inverter components.""" + +import dataclasses + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + BatteryInverter, + ComponentCategory, + ComponentStatus, + HybridInverter, + Inverter, + InverterType, + SolarInverter, + UnrecognizedInverter, + UnspecifiedInverter, +) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class InverterTestCase: + """Test case for Inverter components.""" + + cls: type[UnspecifiedInverter | BatteryInverter | SolarInverter | HybridInverter] + expected_type: InverterType + name: str + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return ComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +def test_abstract_inverter_cannot_be_instantiated( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test that Inverter base class cannot be instantiated.""" + with pytest.raises(TypeError, match="Cannot instantiate Inverter directly"): + Inverter( + id=component_id, + microgrid_id=microgrid_id, + name="test_inverter", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + type=InverterType.BATTERY, + ) + + +@pytest.mark.parametrize( + "case", + [ + InverterTestCase( + cls=UnspecifiedInverter, + expected_type=InverterType.UNSPECIFIED, + name="unspecified", + ), + InverterTestCase( + cls=BatteryInverter, expected_type=InverterType.BATTERY, name="battery" + ), + InverterTestCase( + cls=SolarInverter, expected_type=InverterType.SOLAR, name="solar" + ), + InverterTestCase( + cls=HybridInverter, expected_type=InverterType.HYBRID, name="hybrid" + ), + ], + ids=lambda case: case.name, +) +def test_recognized_inverter_types( + case: InverterTestCase, component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of different recognized inverter types.""" + inverter = case.cls( + id=component_id, + microgrid_id=microgrid_id, + name=case.name, + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert inverter.id == component_id + assert inverter.microgrid_id == microgrid_id + assert inverter.name == case.name + assert inverter.manufacturer == "test_manufacturer" + assert inverter.model_name == "test_model" + assert inverter.status == ComponentStatus.ACTIVE + assert inverter.category == ComponentCategory.INVERTER + assert inverter.type == case.expected_type + + +def test_unrecognized_inverter_type( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of unrecognized inverter type.""" + inverter = UnrecognizedInverter( + id=component_id, + microgrid_id=microgrid_id, + name="unrecognized_inverter", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + type=999, # type is passed here for UnrecognizedInverter + ) + + assert inverter.id == component_id + assert inverter.microgrid_id == microgrid_id + assert inverter.name == "unrecognized_inverter" + assert inverter.manufacturer == "test_manufacturer" + assert inverter.model_name == "test_model" + assert inverter.status == ComponentStatus.ACTIVE + assert inverter.category == ComponentCategory.INVERTER + assert inverter.type == 999 From 509ae9c840c4c8f1a4646498a4f087abc3949511 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 26 May 2025 14:48:38 +0200 Subject: [PATCH 08/12] Add `ProblematicComponent` and sub-classes Problematic components are components that can't be mapped to known category types (or are `UNSPECIFIED`). They also include components with mismatched a category, i.e. a component with a particular known category but that also has category-specific information that doesn't match the specified category. For example if the category is `BATTERY` but the category-specific information is for a `INVERTER`. Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 10 ++ .../client/microgrid/component/_component.py | 8 +- .../microgrid/component/_problematic.py | 50 +++++++ tests/component/test_problematic.py | 140 ++++++++++++++++++ 4 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 src/frequenz/client/microgrid/component/_problematic.py create mode 100644 tests/component/test_problematic.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index ae2cc828..fbb99a3e 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -42,6 +42,12 @@ ) from ._meter import Meter from ._precharger import Precharger +from ._problematic import ( + MismatchedCategoryComponent, + ProblematicComponent, + UnrecognizedComponent, + UnspecifiedComponent, +) from ._relay import Relay from ._status import ComponentStatus from ._voltage_transformer import VoltageTransformer @@ -72,14 +78,18 @@ "InverterType", "LiIonBattery", "Meter", + "MismatchedCategoryComponent", "NaIonBattery", "Precharger", + "ProblematicComponent", "Relay", "SolarInverter", "UnrecognizedBattery", + "UnrecognizedComponent", "UnrecognizedEvCharger", "UnrecognizedInverter", "UnspecifiedBattery", + "UnspecifiedComponent", "UnspecifiedEvCharger", "UnspecifiedInverter", "VoltageTransformer", diff --git a/src/frequenz/client/microgrid/component/_component.py b/src/frequenz/client/microgrid/component/_component.py index 1c182e5e..d4a8d535 100644 --- a/src/frequenz/client/microgrid/component/_component.py +++ b/src/frequenz/client/microgrid/component/_component.py @@ -39,8 +39,9 @@ class Component: # pylint: disable=too-many-instance-attributes [`isinstance`][] of a concrete component class instead. It is only provided for using with a newer version of the API where the client - doesn't know about a new category yet, and in case some low level code needs to - know the category of a component. + doesn't know about a new category yet (i.e. for use with + [`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent]) + and in case some low level code needs to know the category of a component. """ status: ComponentStatus | int = ComponentStatus.UNSPECIFIED @@ -89,7 +90,8 @@ class Component: # pylint: disable=too-many-instance-attributes Note: This should not be used normally, it is only useful when accessing a newer version of the API where the client doesn't know about the new metadata fields - yet. + yet (i.e. for use with + [`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent]). """ def __new__(cls, *_: Any, **__: Any) -> Self: diff --git a/src/frequenz/client/microgrid/component/_problematic.py b/src/frequenz/client/microgrid/component/_problematic.py new file mode 100644 index 00000000..8c2a635a --- /dev/null +++ b/src/frequenz/client/microgrid/component/_problematic.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Unknown component.""" + +import dataclasses +from typing import Any, Literal, Self + +from ._category import ComponentCategory +from ._component import Component + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class ProblematicComponent(Component): + """An abstract component with a problem.""" + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is ProblematicComponent: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnspecifiedComponent(ProblematicComponent): + """A component of unspecified type.""" + + category: Literal[ComponentCategory.UNSPECIFIED] = ComponentCategory.UNSPECIFIED + """The category of this component.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnrecognizedComponent(ProblematicComponent): + """A component of an unrecognized type.""" + + category: int + """The category of this component.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class MismatchedCategoryComponent(ProblematicComponent): + """A component with a mismatch in the category. + + This component declared a category but carries category specific metadata that + doesn't match the declared category. + """ + + category: ComponentCategory | int + """The category of this component.""" diff --git a/tests/component/test_problematic.py b/tests/component/test_problematic.py new file mode 100644 index 00000000..8ab8feba --- /dev/null +++ b/tests/component/test_problematic.py @@ -0,0 +1,140 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for ProblematicComponent components.""" + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + ComponentStatus, + MismatchedCategoryComponent, + ProblematicComponent, + UnrecognizedComponent, + UnspecifiedComponent, +) + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return ComponentId(42) + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return MicrogridId(1) + + +def test_abstract_problematic_component_cannot_be_instantiated( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test that ProblematicComponent base class cannot be instantiated.""" + with pytest.raises( + TypeError, match="Cannot instantiate ProblematicComponent directly" + ): + ProblematicComponent( + id=component_id, + microgrid_id=microgrid_id, + name="test_problematic", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + category=ComponentCategory.UNSPECIFIED, + ) + + +def test_unspecified_component( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of UnspecifiedComponent.""" + component = UnspecifiedComponent( + id=component_id, + microgrid_id=microgrid_id, + name="unspecified_component", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "unspecified_component" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == ComponentCategory.UNSPECIFIED + + +def test_mismatched_category_component_with_known_category( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test MismatchedCategoryComponent with a known ComponentCategory.""" + expected_category = ComponentCategory.BATTERY + component = MismatchedCategoryComponent( + id=component_id, + microgrid_id=microgrid_id, + name="mismatched_battery", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + category=expected_category, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "mismatched_battery" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == expected_category + + +def test_mismatched_category_component_with_unrecognized_category( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test MismatchedCategoryComponent with an unrecognized integer category.""" + expected_category = 999 + component = MismatchedCategoryComponent( + id=component_id, + microgrid_id=microgrid_id, + name="mismatched_unrecognized", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + category=expected_category, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "mismatched_unrecognized" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == expected_category + + +def test_unrecognized_component_type( + component_id: ComponentId, microgrid_id: MicrogridId +) -> None: + """Test initialization and properties of UnrecognizedComponent type.""" + component = UnrecognizedComponent( + id=component_id, + microgrid_id=microgrid_id, + name="unrecognized_component", + manufacturer="test_manufacturer", + model_name="test_model", + status=ComponentStatus.ACTIVE, + category=999, + ) + + assert component.id == component_id + assert component.microgrid_id == microgrid_id + assert component.name == "unrecognized_component" + assert component.manufacturer == "test_manufacturer" + assert component.model_name == "test_model" + assert component.status == ComponentStatus.ACTIVE + assert component.category == 999 From 8c21744d3619aa054bc236d3974eb7af4b047185 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 26 May 2025 14:53:03 +0200 Subject: [PATCH 09/12] Add component types aliases These aliases are type unions for all supported components, and for all known problematic, unspecified and unrecognized components. These include the specific types of components with sub-types, like batteries, EV chargers and inverters. These type aliases only include concrete types, the base types are not included, this way they can be used as type hints where match statements should be used. Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/__init__.py | 10 +++ .../client/microgrid/component/_types.py | 65 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_types.py diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index fbb99a3e..3b0b77f4 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -50,6 +50,12 @@ ) from ._relay import Relay from ._status import ComponentStatus +from ._types import ( + ComponentTypes, + ProblematicComponentTypes, + UnrecognizedComponentTypes, + UnspecifiedComponentTypes, +) from ._voltage_transformer import VoltageTransformer __all__ = [ @@ -62,6 +68,7 @@ "Component", "ComponentCategory", "ComponentStatus", + "ComponentTypes", "Converter", "CryptoMiner", "DcEvCharger", @@ -82,14 +89,17 @@ "NaIonBattery", "Precharger", "ProblematicComponent", + "ProblematicComponentTypes", "Relay", "SolarInverter", "UnrecognizedBattery", "UnrecognizedComponent", + "UnrecognizedComponentTypes", "UnrecognizedEvCharger", "UnrecognizedInverter", "UnspecifiedBattery", "UnspecifiedComponent", + "UnspecifiedComponentTypes", "UnspecifiedEvCharger", "UnspecifiedInverter", "VoltageTransformer", diff --git a/src/frequenz/client/microgrid/component/_types.py b/src/frequenz/client/microgrid/component/_types.py new file mode 100644 index 00000000..e362ab73 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_types.py @@ -0,0 +1,65 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""All known component types.""" + +from typing import TypeAlias + +from ._battery import BatteryTypes, UnrecognizedBattery, UnspecifiedBattery +from ._chp import Chp +from ._converter import Converter +from ._crypto_miner import CryptoMiner +from ._electrolyzer import Electrolyzer +from ._ev_charger import EvChargerTypes, UnrecognizedEvCharger, UnspecifiedEvCharger +from ._fuse import Fuse +from ._grid_connection_point import GridConnectionPoint +from ._hvac import Hvac +from ._inverter import InverterTypes, UnrecognizedInverter, UnspecifiedInverter +from ._meter import Meter +from ._precharger import Precharger +from ._problematic import ( + MismatchedCategoryComponent, + UnrecognizedComponent, + UnspecifiedComponent, +) +from ._relay import Relay +from ._voltage_transformer import VoltageTransformer + +UnspecifiedComponentTypes: TypeAlias = ( + UnspecifiedBattery + | UnspecifiedComponent + | UnspecifiedEvCharger + | UnspecifiedInverter +) +"""All unspecified component types.""" + +UnrecognizedComponentTypes: TypeAlias = ( + UnrecognizedBattery + | UnrecognizedComponent + | UnrecognizedEvCharger + | UnrecognizedInverter +) + +ProblematicComponentTypes: TypeAlias = ( + MismatchedCategoryComponent | UnrecognizedComponentTypes | UnspecifiedComponentTypes +) +"""All possible component types that has a problem.""" + +ComponentTypes: TypeAlias = ( + BatteryTypes + | Chp + | Converter + | CryptoMiner + | Electrolyzer + | EvChargerTypes + | Fuse + | GridConnectionPoint + | Hvac + | InverterTypes + | Meter + | Precharger + | ProblematicComponentTypes + | Relay + | VoltageTransformer +) +"""All possible component types.""" From cbfb3173878d3cef566a03e35556582dd6efaa9d Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 12 Jun 2025 15:02:09 +0200 Subject: [PATCH 10/12] Improve lifetime validation exception The message now includes the problematic values. Signed-off-by: Leandro Lucarella --- src/frequenz/client/microgrid/_lifetime.py | 4 +++- tests/test_lifetime.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/frequenz/client/microgrid/_lifetime.py b/src/frequenz/client/microgrid/_lifetime.py index fa1ab04e..c2bd7074 100644 --- a/src/frequenz/client/microgrid/_lifetime.py +++ b/src/frequenz/client/microgrid/_lifetime.py @@ -33,7 +33,9 @@ class Lifetime: def __post_init__(self) -> None: """Validate this lifetime.""" if self.start is not None and self.end is not None and self.start > self.end: - raise ValueError("Start must be before or equal to end.") + raise ValueError( + f"Start ({self.start}) must be before or equal to end ({self.end})" + ) def is_operational_at(self, timestamp: datetime) -> bool: """Check whether this lifetime is active at a specific timestamp.""" diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index ef9fd6fb..0de6d0e5 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -200,7 +200,9 @@ def test_validation( ) if should_fail: - with pytest.raises(ValueError, match="Start must be before or equal to end."): + with pytest.raises( + ValueError, match=r"Start \(.*\) must be before or equal to end \(.*\)" + ): Lifetime(start=start_time, end=end_time) else: lifetime = Lifetime(start=start_time, end=end_time) From c43dafcca5a4f53967c93e649a4b5b9ffaabfa55 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Tue, 27 May 2025 12:50:43 +0200 Subject: [PATCH 11/12] Add parsing of components from protobuf messages The `component_from_proto()` function parses protobuf `Component` messages and create the appropriate and specific component type of our component hierarchy. During parsing, a few consistency checks are done in the protobuf message. Solvable errors are recovered as much as possible. Major issues are logged as warnings, and minor issues are logged as debug. Signed-off-by: Leandro Lucarella --- .../microgrid/component/_component_proto.py | 562 ++++++++++++++++++ tests/component/component_proto/__init__.py | 4 + tests/component/component_proto/conftest.py | 123 ++++ tests/component/component_proto/test_base.py | 128 ++++ .../component/component_proto/test_simple.py | 265 +++++++++ .../component_proto/test_with_type.py | 233 ++++++++ 6 files changed, 1315 insertions(+) create mode 100644 src/frequenz/client/microgrid/component/_component_proto.py create mode 100644 tests/component/component_proto/__init__.py create mode 100644 tests/component/component_proto/conftest.py create mode 100644 tests/component/component_proto/test_base.py create mode 100644 tests/component/component_proto/test_simple.py create mode 100644 tests/component/component_proto/test_with_type.py diff --git a/src/frequenz/client/microgrid/component/_component_proto.py b/src/frequenz/client/microgrid/component/_component_proto.py new file mode 100644 index 00000000..21e9c697 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_component_proto.py @@ -0,0 +1,562 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Loading of Component objects from protobuf messages.""" + +import logging +from collections.abc import Sequence +from typing import Any, NamedTuple, assert_never + +from frequenz.api.common.v1.microgrid.components import components_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId +from google.protobuf.json_format import MessageToDict + +from .._lifetime import Lifetime +from .._lifetime_proto import lifetime_from_proto +from .._util import enum_from_proto +from ..metrics._bounds import Bounds +from ..metrics._bounds_proto import bounds_from_proto +from ..metrics._metric import Metric +from ._battery import ( + BatteryType, + LiIonBattery, + NaIonBattery, + UnrecognizedBattery, + UnspecifiedBattery, +) +from ._category import ComponentCategory +from ._chp import Chp +from ._converter import Converter +from ._crypto_miner import CryptoMiner +from ._electrolyzer import Electrolyzer +from ._ev_charger import ( + AcEvCharger, + DcEvCharger, + EvChargerType, + HybridEvCharger, + UnrecognizedEvCharger, + UnspecifiedEvCharger, +) +from ._fuse import Fuse +from ._grid_connection_point import GridConnectionPoint +from ._hvac import Hvac +from ._inverter import ( + BatteryInverter, + HybridInverter, + InverterType, + SolarInverter, + UnrecognizedInverter, + UnspecifiedInverter, +) +from ._meter import Meter +from ._precharger import Precharger +from ._problematic import ( + MismatchedCategoryComponent, + UnrecognizedComponent, + UnspecifiedComponent, +) +from ._relay import Relay +from ._status import ComponentStatus +from ._types import ComponentTypes +from ._voltage_transformer import VoltageTransformer + +_logger = logging.getLogger(__name__) + + +# We disable the `too-many-arguments` check in the whole file because all _from_proto +# functions are expected to take many arguments. +# pylint: disable=too-many-arguments + + +def component_from_proto(message: components_pb2.Component) -> ComponentTypes: + """Convert a protobuf message to a `Component` instance. + + Args: + message: The protobuf message. + + Returns: + The resulting `Component` instance. + """ + major_issues: list[str] = [] + minor_issues: list[str] = [] + + component = component_from_proto_with_issues( + message, major_issues=major_issues, minor_issues=minor_issues + ) + + if major_issues: + _logger.warning( + "Found issues in component: %s | Protobuf message:\n%s", + ", ".join(major_issues), + message, + ) + if minor_issues: + _logger.debug( + "Found minor issues in component: %s | Protobuf message:\n%s", + ", ".join(minor_issues), + message, + ) + + return component + + +class ComponentBaseData(NamedTuple): + """Base data for a component, extracted from a protobuf message.""" + + component_id: ComponentId + microgrid_id: MicrogridId + name: str | None + manufacturer: str | None + model_name: str | None + category: ComponentCategory | int + status: ComponentStatus | int + lifetime: Lifetime + rated_bounds: dict[Metric | int, Bounds] + category_specific_metadata: dict[str, Any] + category_mismatched: bool = False + + +def component_base_from_proto_with_issues( + message: components_pb2.Component, + *, + major_issues: list[str], + minor_issues: list[str], +) -> ComponentBaseData: + """Extract base data from a protobuf message and collect issues. + + Args: + message: The protobuf message. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + A `ComponentBaseData` named tuple containing the extracted data. + """ + component_id = ComponentId(message.id) + microgrid_id = MicrogridId(message.microgrid_id) + + name = message.name or None + if name is None: + minor_issues.append("name is empty") + + manufacturer = message.manufacturer or None + if manufacturer is None: + minor_issues.append("manufacturer is empty") + + model_name = message.model_name or None + if model_name is None: + minor_issues.append("model_name is empty") + + status = enum_from_proto(message.status, ComponentStatus) + if status is ComponentStatus.UNSPECIFIED: + major_issues.append("status is unspecified") + elif isinstance(status, int): + major_issues.append("status is unrecognized") + + lifetime = _get_operational_lifetime_from_proto( + message, major_issues=major_issues, minor_issues=minor_issues + ) + + rated_bounds = _metric_config_bounds_from_proto( + message.metric_config_bounds, + major_issues=major_issues, + minor_issues=minor_issues, + ) + + category = enum_from_proto(message.category, ComponentCategory) + if category is ComponentCategory.UNSPECIFIED: + major_issues.append("category is unspecified") + elif isinstance(category, int): + major_issues.append(f"category {category} is unrecognized") + + metadata_category = message.category_type.WhichOneof("metadata") + category_specific_metadata: dict[str, Any] = {} + if metadata_category is not None: + category_specific_metadata = MessageToDict( + getattr(message.category_type, metadata_category), + always_print_fields_with_no_presence=True, + ) + + category_mismatched = False + if ( + metadata_category + and isinstance(category, ComponentCategory) + and category.name.lower() != metadata_category + ): + major_issues.append("category_type.metadata does not match the category_type") + category_mismatched = True + + return ComponentBaseData( + component_id, + microgrid_id, + name, + manufacturer, + model_name, + category, + status, + lifetime, + rated_bounds, + category_specific_metadata, + category_mismatched, + ) + + +# pylint: disable-next=too-many-locals +def component_from_proto_with_issues( + message: components_pb2.Component, + *, + major_issues: list[str], + minor_issues: list[str], +) -> ComponentTypes: + """Convert a protobuf message to a `Component` instance and collect issues. + + Args: + message: The protobuf message. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + The resulting `Component` instance. + """ + base_data = component_base_from_proto_with_issues( + message, major_issues=major_issues, minor_issues=minor_issues + ) + + if base_data.category_mismatched: + return MismatchedCategoryComponent( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + category=base_data.category, + operational_lifetime=base_data.lifetime, + category_specific_metadata=base_data.category_specific_metadata, + rated_bounds=base_data.rated_bounds, + ) + + match base_data.category: + case int(): + return UnrecognizedComponent( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + category=base_data.category, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + ) + case ( + ComponentCategory.UNSPECIFIED + | ComponentCategory.CHP + | ComponentCategory.CONVERTER + | ComponentCategory.CRYPTO_MINER + | ComponentCategory.ELECTROLYZER + | ComponentCategory.HVAC + | ComponentCategory.METER + | ComponentCategory.PRECHARGER + | ComponentCategory.RELAY + ): + return _trivial_category_to_class(base_data.category)( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + ) + case ComponentCategory.BATTERY: + battery_enum_to_class: dict[ + BatteryType, type[UnspecifiedBattery | LiIonBattery | NaIonBattery] + ] = { + BatteryType.UNSPECIFIED: UnspecifiedBattery, + BatteryType.LI_ION: LiIonBattery, + BatteryType.NA_ION: NaIonBattery, + } + battery_type = enum_from_proto( + message.category_type.battery.type, BatteryType + ) + match battery_type: + case BatteryType.UNSPECIFIED | BatteryType.LI_ION | BatteryType.NA_ION: + if battery_type is BatteryType.UNSPECIFIED: + major_issues.append("battery type is unspecified") + return battery_enum_to_class[battery_type]( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + ) + case int(): + major_issues.append(f"battery type {battery_type} is unrecognized") + return UnrecognizedBattery( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + type=battery_type, + ) + case unexpected_battery_type: + assert_never(unexpected_battery_type) + case ComponentCategory.EV_CHARGER: + ev_charger_enum_to_class: dict[ + EvChargerType, + type[ + UnspecifiedEvCharger | AcEvCharger | DcEvCharger | HybridEvCharger + ], + ] = { + EvChargerType.UNSPECIFIED: UnspecifiedEvCharger, + EvChargerType.AC: AcEvCharger, + EvChargerType.DC: DcEvCharger, + EvChargerType.HYBRID: HybridEvCharger, + } + ev_charger_type = enum_from_proto( + message.category_type.ev_charger.type, EvChargerType + ) + match ev_charger_type: + case ( + EvChargerType.UNSPECIFIED + | EvChargerType.AC + | EvChargerType.DC + | EvChargerType.HYBRID + ): + if ev_charger_type is EvChargerType.UNSPECIFIED: + major_issues.append("ev_charger type is unspecified") + return ev_charger_enum_to_class[ev_charger_type]( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + ) + case int(): + major_issues.append( + f"ev_charger type {ev_charger_type} is unrecognized" + ) + return UnrecognizedEvCharger( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + type=ev_charger_type, + ) + case unexpected_ev_charger_type: + assert_never(unexpected_ev_charger_type) + case ComponentCategory.FUSE: + rated_current = message.category_type.fuse.rated_current + # No need to check for negatives because the protobuf type is uint32. + return Fuse( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + rated_current=rated_current, + ) + case ComponentCategory.GRID: + rated_fuse_current = message.category_type.grid.rated_fuse_current + # No need to check for negatives because the protobuf type is uint32. + return GridConnectionPoint( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + rated_fuse_current=rated_fuse_current, + ) + case ComponentCategory.INVERTER: + inverter_enum_to_class: dict[ + InverterType, + type[ + UnspecifiedInverter + | BatteryInverter + | SolarInverter + | HybridInverter + ], + ] = { + InverterType.UNSPECIFIED: UnspecifiedInverter, + InverterType.BATTERY: BatteryInverter, + InverterType.SOLAR: SolarInverter, + InverterType.HYBRID: HybridInverter, + } + inverter_type = enum_from_proto( + message.category_type.inverter.type, InverterType + ) + match inverter_type: + case ( + InverterType.UNSPECIFIED + | InverterType.BATTERY + | InverterType.SOLAR + | InverterType.HYBRID + ): + if inverter_type is InverterType.UNSPECIFIED: + major_issues.append("inverter type is unspecified") + return inverter_enum_to_class[inverter_type]( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + ) + case int(): + major_issues.append( + f"inverter type {inverter_type} is unrecognized" + ) + return UnrecognizedInverter( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + type=inverter_type, + ) + case unexpected_inverter_type: + assert_never(unexpected_inverter_type) + case ComponentCategory.VOLTAGE_TRANSFORMER: + return VoltageTransformer( + id=base_data.component_id, + microgrid_id=base_data.microgrid_id, + name=base_data.name, + manufacturer=base_data.manufacturer, + model_name=base_data.model_name, + status=base_data.status, + operational_lifetime=base_data.lifetime, + rated_bounds=base_data.rated_bounds, + primary_voltage=message.category_type.voltage_transformer.primary, + secondary_voltage=message.category_type.voltage_transformer.secondary, + ) + case unexpected_category: + assert_never(unexpected_category) + + +def _trivial_category_to_class( + category: ComponentCategory, +) -> type[ + UnspecifiedComponent + | Chp + | Converter + | CryptoMiner + | Electrolyzer + | Hvac + | Meter + | Precharger + | Relay +]: + """Return the class corresponding to a trivial component category.""" + return { + ComponentCategory.UNSPECIFIED: UnspecifiedComponent, + ComponentCategory.CHP: Chp, + ComponentCategory.CONVERTER: Converter, + ComponentCategory.CRYPTO_MINER: CryptoMiner, + ComponentCategory.ELECTROLYZER: Electrolyzer, + ComponentCategory.HVAC: Hvac, + ComponentCategory.METER: Meter, + ComponentCategory.PRECHARGER: Precharger, + ComponentCategory.RELAY: Relay, + }[category] + + +def _metric_config_bounds_from_proto( + message: Sequence[components_pb2.MetricConfigBounds], + *, + major_issues: list[str], + minor_issues: list[str], # pylint: disable=unused-argument +) -> dict[Metric | int, Bounds]: + """Convert a `MetricConfigBounds` message to a dictionary of `Metric` to `Bounds`. + + Args: + message: The `MetricConfigBounds` message. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + The resulting dictionary of `Metric` to `Bounds`. + """ + bounds: dict[Metric | int, Bounds] = {} + for metric_bound in message: + metric = enum_from_proto(metric_bound.metric, Metric) + match metric: + case Metric.UNSPECIFIED: + major_issues.append("metric_config_bounds has an UNSPECIFIED metric") + case int(): + minor_issues.append( + f"metric_config_bounds has an unrecognized metric {metric}" + ) + + if not metric_bound.HasField("config_bounds"): + major_issues.append( + f"metric_config_bounds for {metric} is present but missing " + "`config_bounds`, considering it unbounded", + ) + continue + + try: + bound = bounds_from_proto(metric_bound.config_bounds) + except ValueError as exc: + major_issues.append( + f"metric_config_bounds for {metric} is invalid ({exc}), considering " + "it as missing (i.e. unbouded)", + ) + continue + if metric in bounds: + major_issues.append( + f"metric_config_bounds for {metric} is duplicated in the message" + f"using the last one ({bound})", + ) + bounds[metric] = bound + + return bounds + + +def _get_operational_lifetime_from_proto( + message: components_pb2.Component, + *, + major_issues: list[str], + minor_issues: list[str], +) -> Lifetime: + """Get the operational lifetime from a protobuf message.""" + if message.HasField("operational_lifetime"): + try: + return lifetime_from_proto(message.operational_lifetime) + except ValueError as exc: + major_issues.append( + f"invalid operational lifetime ({exc}), considering it as missing " + "(i.e. always operational)", + ) + else: + minor_issues.append( + "missing operational lifetime, considering it always operational", + ) + return Lifetime() diff --git a/tests/component/component_proto/__init__.py b/tests/component/component_proto/__init__.py new file mode 100644 index 00000000..377b71f3 --- /dev/null +++ b/tests/component/component_proto/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for protobuf conversion of Component objects.""" diff --git a/tests/component/component_proto/conftest.py b/tests/component/component_proto/conftest.py new file mode 100644 index 00000000..b1d2a09f --- /dev/null +++ b/tests/component/component_proto/conftest.py @@ -0,0 +1,123 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Fixtures and utilities for testing component protobuf conversion.""" + +from datetime import datetime, timezone + +import pytest +from frequenz.api.common.v1.metrics import bounds_pb2 +from frequenz.api.common.v1.microgrid import lifetime_pb2 +from frequenz.api.common.v1.microgrid.components import components_pb2 +from frequenz.client.base.conversion import to_timestamp +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId +from google.protobuf.timestamp_pb2 import Timestamp + +from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid.component import ( + Component, + ComponentCategory, + ComponentStatus, +) +from frequenz.client.microgrid.component._component_proto import ComponentBaseData +from frequenz.client.microgrid.metrics import Bounds, Metric + +DEFAULT_LIFETIME = Lifetime( + start=datetime(2020, 1, 1, tzinfo=timezone.utc), + end=datetime(2030, 1, 1, tzinfo=timezone.utc), +) +DEFAULT_COMPONENT_ID = ComponentId(42) +DEFAULT_MICROGRID_ID = MicrogridId(1) +DEFAULT_NAME = "test_component" +DEFAULT_MANUFACTURER = "test_manufacturer" +DEFAULT_MODEL_NAME = "test_model" + + +@pytest.fixture +def component_id() -> ComponentId: + """Provide a test component ID.""" + return DEFAULT_COMPONENT_ID + + +@pytest.fixture +def microgrid_id() -> MicrogridId: + """Provide a test microgrid ID.""" + return DEFAULT_MICROGRID_ID + + +@pytest.fixture +def default_component_base_data( + component_id: ComponentId, microgrid_id: MicrogridId +) -> ComponentBaseData: + """Provide a fixture for common component fields.""" + return ComponentBaseData( + component_id=component_id, + microgrid_id=microgrid_id, + name=DEFAULT_NAME, + manufacturer=DEFAULT_MANUFACTURER, + model_name=DEFAULT_MODEL_NAME, + category=ComponentCategory.UNSPECIFIED, + status=ComponentStatus.ACTIVE, + lifetime=DEFAULT_LIFETIME, + rated_bounds={Metric.AC_ACTIVE_ENERGY: Bounds(lower=0, upper=100)}, + category_specific_metadata={}, + category_mismatched=False, + ) + + +def assert_base_data(base_data: ComponentBaseData, other: Component) -> None: + """Assert this ComponentBaseData equals a Component.""" + assert base_data.component_id == other.id + assert base_data.microgrid_id == other.microgrid_id + assert base_data.name == other.name + assert base_data.manufacturer == other.manufacturer + assert base_data.model_name == other.model_name + assert base_data.category == other.category + assert base_data.status == other.status + assert base_data.lifetime == other.operational_lifetime + assert base_data.rated_bounds == other.rated_bounds + assert base_data.category_specific_metadata == other.category_specific_metadata + + +def base_data_as_proto(base_data: ComponentBaseData) -> components_pb2.Component: + """Convert this ComponentBaseData to a protobuf Component.""" + proto = components_pb2.Component( + id=int(base_data.component_id), + microgrid_id=int(base_data.microgrid_id), + name=base_data.name or "", + manufacturer=base_data.manufacturer or "", + model_name=base_data.model_name or "", + status=( + base_data.status + if isinstance(base_data.status, int) + else int(base_data.status.value) # type: ignore[arg-type] + ), + category=( + base_data.category + if isinstance(base_data.category, int) + else int(base_data.category.value) # type: ignore[arg-type] + ), + ) + if base_data.lifetime: + lifetime_dict: dict[str, Timestamp] = {} + if base_data.lifetime.start is not None: + lifetime_dict["start_timestamp"] = to_timestamp(base_data.lifetime.start) + if base_data.lifetime.end is not None: + lifetime_dict["end_timestamp"] = to_timestamp(base_data.lifetime.end) + proto.operational_lifetime.CopyFrom(lifetime_pb2.Lifetime(**lifetime_dict)) + if base_data.rated_bounds: + for metric, bounds in base_data.rated_bounds.items(): + bounds_dict: dict[str, float] = {} + if bounds.lower is not None: + bounds_dict["lower"] = bounds.lower + if bounds.upper is not None: + bounds_dict["upper"] = bounds.upper + metric_value = metric.value if isinstance(metric, Metric) else metric + proto.metric_config_bounds.append( + components_pb2.MetricConfigBounds( + metric=metric_value, # type: ignore[arg-type] + config_bounds=bounds_pb2.Bounds(**bounds_dict), + ) + ) + return proto diff --git a/tests/component/component_proto/test_base.py b/tests/component/component_proto/test_base.py new file mode 100644 index 00000000..4c20ae97 --- /dev/null +++ b/tests/component/component_proto/test_base.py @@ -0,0 +1,128 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for protobuf conversion of the base/common part of Component objects.""" + + +import pytest +from frequenz.api.common.v1.microgrid.components import battery_pb2 +from google.protobuf.timestamp_pb2 import Timestamp + +from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid.component import ComponentCategory, ComponentStatus +from frequenz.client.microgrid.component._component_proto import ( + ComponentBaseData, + component_base_from_proto_with_issues, +) + +from .conftest import base_data_as_proto + + +def test_complete(default_component_base_data: ComponentBaseData) -> None: + """Test parsing of a complete base component proto.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace( + category=ComponentCategory.CHP, # Just to pick a valid category + ) + proto = base_data_as_proto(base_data) + parsed = component_base_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert not major_issues + assert not minor_issues + assert parsed == base_data + + +@pytest.mark.parametrize("status", [ComponentStatus.UNSPECIFIED, 999]) +def test_missing_metadata( + default_component_base_data: ComponentBaseData, status: ComponentStatus | int +) -> None: + """Test parsing with missing optional metadata.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace( + name=None, + manufacturer=None, + model_name=None, + category=ComponentCategory.UNSPECIFIED, + status=status, + lifetime=Lifetime(), + rated_bounds={}, + category_specific_metadata={}, + ) + proto = base_data_as_proto(base_data) + proto.ClearField("operational_lifetime") + proto.ClearField("metric_config_bounds") + + parsed = component_base_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + expected_major_issues = ["category is unspecified"] + if status == ComponentStatus.UNSPECIFIED: + expected_major_issues.append("status is unspecified") + else: + expected_major_issues.append("status is unrecognized") + assert sorted(major_issues) == sorted(expected_major_issues) + assert sorted(minor_issues) == sorted( + [ + "name is empty", + "manufacturer is empty", + "model_name is empty", + "missing operational lifetime, considering it always operational", + ] + ) + assert parsed == base_data + + +def test_category_metadata_mismatch( + default_component_base_data: ComponentBaseData, +) -> None: + """Test category and metadata mismatch.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace( + category=ComponentCategory.GRID, + category_specific_metadata={"type": "BATTERY_TYPE_LI_ION"}, + category_mismatched=True, + ) + proto = base_data_as_proto(base_data) + proto.category_type.battery.type = battery_pb2.BATTERY_TYPE_LI_ION + + parsed = component_base_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + # Actual message from _component_base_from_proto_with_issues + assert major_issues == ["category_type.metadata does not match the category_type"] + assert not minor_issues + assert parsed == base_data + + +def test_invalid_lifetime(default_component_base_data: ComponentBaseData) -> None: + """Test parsing with missing optional metadata.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace( + category=ComponentCategory.CHP, lifetime=Lifetime() + ) + proto = base_data_as_proto(base_data) + proto.operational_lifetime.start_timestamp.CopyFrom( + Timestamp(seconds=1696204800) # 2023-10-02T00:00:00Z + ) + proto.operational_lifetime.end_timestamp.CopyFrom( + Timestamp(seconds=1696118400) # 2023-10-01T00:00:00Z + ) + + parsed = component_base_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert major_issues == [ + "invalid operational lifetime (Start (2023-10-02 00:00:00+00:00) must be " + "before or equal to end (2023-10-01 00:00:00+00:00)), considering it as " + "missing (i.e. always operational)" + ] + assert not minor_issues + assert parsed == base_data diff --git a/tests/component/component_proto/test_simple.py b/tests/component/component_proto/test_simple.py new file mode 100644 index 00000000..960a27d6 --- /dev/null +++ b/tests/component/component_proto/test_simple.py @@ -0,0 +1,265 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for protobuf conversion of simple Component objects.""" + + +import logging +from unittest.mock import Mock, patch + +import pytest +from frequenz.api.common.v1.microgrid.components import battery_pb2, components_pb2 + +from frequenz.client.microgrid.component import ( + Chp, + Component, + ComponentCategory, + Converter, + CryptoMiner, + Electrolyzer, + Fuse, + GridConnectionPoint, + Hvac, + Meter, + MismatchedCategoryComponent, + Precharger, + Relay, + UnrecognizedComponent, + UnspecifiedComponent, + VoltageTransformer, +) +from frequenz.client.microgrid.component._component_proto import ( + ComponentBaseData, + component_from_proto, + component_from_proto_with_issues, +) + +from .conftest import assert_base_data, base_data_as_proto + + +def test_unspecified(default_component_base_data: ComponentBaseData) -> None: + """Test Component with unspecified category.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + proto = base_data_as_proto(default_component_base_data) + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert major_issues == ["category is unspecified"] + assert not minor_issues + assert isinstance(component, UnspecifiedComponent) + assert_base_data(default_component_base_data, component) + assert component.category == ComponentCategory.UNSPECIFIED + + +def test_unrecognized(default_component_base_data: ComponentBaseData) -> None: + """Test Component with unrecognized category.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace(category=999) + proto = base_data_as_proto(base_data) + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert major_issues == ["category 999 is unrecognized"] + assert not minor_issues + assert isinstance(component, UnrecognizedComponent) + assert_base_data(base_data, component) + assert component.category == 999 + + +def test_category_mismatch(default_component_base_data: ComponentBaseData) -> None: + """Test MismatchedCategoryComponent for category GRID and battery metadata.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace( + category=ComponentCategory.GRID, + category_specific_metadata={"type": "BATTERY_TYPE_LI_ION"}, + category_mismatched=True, + ) + proto = base_data_as_proto(base_data) + proto.category_type.battery.type = battery_pb2.BATTERY_TYPE_LI_ION + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + # The actual message from component_from_proto_with_issues via + # _component_base_from_proto_with_issues + assert major_issues == ["category_type.metadata does not match the category_type"] + assert not minor_issues + assert isinstance(component, MismatchedCategoryComponent) + assert_base_data(base_data, component) + assert component.category == ComponentCategory.GRID + + +@pytest.mark.parametrize( + "category,component_class", + [ + pytest.param(ComponentCategory.CHP, Chp, id="Chp"), + pytest.param(ComponentCategory.CONVERTER, Converter, id="Converter"), + pytest.param(ComponentCategory.CRYPTO_MINER, CryptoMiner, id="CryptoMiner"), + pytest.param(ComponentCategory.ELECTROLYZER, Electrolyzer, id="Electrolyzer"), + pytest.param(ComponentCategory.HVAC, Hvac, id="Hvac"), + pytest.param(ComponentCategory.METER, Meter, id="Meter"), + pytest.param(ComponentCategory.PRECHARGER, Precharger, id="Precharger"), + pytest.param(ComponentCategory.RELAY, Relay, id="Relay"), + ], +) +def test_trivial( + category: ComponentCategory, + component_class: type[Component], + default_component_base_data: ComponentBaseData, +) -> None: + """Test component types that don't need special handling.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace(category=category) + proto = base_data_as_proto(base_data) + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert not major_issues + assert not minor_issues + assert isinstance(component, component_class) + + +@pytest.mark.parametrize("primary", [None, -10.0, 0.0, 230.0]) +@pytest.mark.parametrize("secondary", [None, -34.5, 0.0, 400.0]) +def test_voltage_transformer( + default_component_base_data: ComponentBaseData, + primary: float | None, + secondary: float | None, +) -> None: + """Test VoltageTransformer component.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace( + category=ComponentCategory.VOLTAGE_TRANSFORMER + ) + + proto = base_data_as_proto(base_data) + if primary is not None: + proto.category_type.voltage_transformer.primary = primary + if secondary is not None: + proto.category_type.voltage_transformer.secondary = secondary + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert not major_issues + assert not minor_issues + assert isinstance(component, VoltageTransformer) + assert_base_data(base_data, component) + assert component.primary_voltage == ( + pytest.approx(primary if primary is not None else 0.0) + ) + assert component.secondary_voltage == ( + pytest.approx(secondary if secondary is not None else 0.0) + ) + + +@pytest.mark.parametrize("rated_current", [None, 0, 23]) +def test_fuse( + default_component_base_data: ComponentBaseData, + rated_current: int | None, +) -> None: + """Test Fuse component with default values.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace(category=ComponentCategory.FUSE) + + proto = base_data_as_proto(base_data) + if rated_current is not None: + proto.category_type.fuse.rated_current = rated_current + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert not major_issues + assert not minor_issues + assert isinstance(component, Fuse) + assert_base_data(base_data, component) + assert component.rated_current == ( + rated_current if rated_current is not None else 0 + ) + + +@pytest.mark.parametrize("rated_fuse_current", [None, 0, 23]) +def test_grid( + default_component_base_data: ComponentBaseData, + rated_fuse_current: int | None, +) -> None: + """Test GridConnectionPoint component with default values.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace(category=ComponentCategory.GRID) + + proto = base_data_as_proto(base_data) + if rated_fuse_current is not None: + proto.category_type.grid.rated_fuse_current = rated_fuse_current + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert not major_issues + assert not minor_issues + assert isinstance(component, GridConnectionPoint) + assert_base_data(base_data, component) + assert component.rated_fuse_current == ( + rated_fuse_current if rated_fuse_current is not None else 0 + ) + + +@patch( + "frequenz.client.microgrid.component._component_proto." + "component_from_proto_with_issues", + autospec=True, +) +def test_issues_logging( + mock_from_proto_with_issues: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test collection and logging of issues during proto conversion.""" + caplog.set_level("DEBUG") # Ensure we capture DEBUG level messages + + mock_component = Mock(name="component", spec=Component) + + def _fake_from_proto_with_issues( + _: components_pb2.Component, + *, + major_issues: list[str], + minor_issues: list[str], + ) -> Component: + """Fake function to simulate conversion and logging.""" + major_issues.append("fake major issue") + minor_issues.append("fake minor issue") + return mock_component + + mock_from_proto_with_issues.side_effect = _fake_from_proto_with_issues + + mock_proto = Mock(name="proto", spec=components_pb2.Component) + component = component_from_proto(mock_proto) + + assert component is mock_component + assert caplog.record_tuples == [ + ( + "frequenz.client.microgrid.component._component_proto", + logging.WARNING, + "Found issues in component: fake major issue | " + f"Protobuf message:\n{mock_proto}", + ), + ( + "frequenz.client.microgrid.component._component_proto", + logging.DEBUG, + "Found minor issues in component: fake minor issue | " + f"Protobuf message:\n{mock_proto}", + ), + ] diff --git a/tests/component/component_proto/test_with_type.py b/tests/component/component_proto/test_with_type.py new file mode 100644 index 00000000..99640d5b --- /dev/null +++ b/tests/component/component_proto/test_with_type.py @@ -0,0 +1,233 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for protobuf conversion of components with a type.""" + +import pytest +from frequenz.api.common.v1.microgrid.components import ( + battery_pb2, + ev_charger_pb2, + inverter_pb2, +) + +from frequenz.client.microgrid.component import ( + AcEvCharger, + Battery, + BatteryInverter, + BatteryType, + ComponentCategory, + DcEvCharger, + EvCharger, + EvChargerType, + HybridEvCharger, + HybridInverter, + Inverter, + InverterType, + LiIonBattery, + NaIonBattery, + SolarInverter, + UnrecognizedBattery, + UnrecognizedEvCharger, + UnrecognizedInverter, + UnspecifiedBattery, + UnspecifiedEvCharger, + UnspecifiedInverter, +) +from frequenz.client.microgrid.component._component_proto import ( + ComponentBaseData, + component_from_proto_with_issues, +) + +from .conftest import assert_base_data, base_data_as_proto + + +@pytest.mark.parametrize( + "battery_class, battery_type, pb_battery_type, expected_major_issues", + [ + pytest.param( + LiIonBattery, + BatteryType.LI_ION, + battery_pb2.BATTERY_TYPE_LI_ION, + [], + id="LI_ION", + ), + pytest.param( + NaIonBattery, + BatteryType.NA_ION, + battery_pb2.BATTERY_TYPE_NA_ION, + [], + id="NA_ION", + ), + pytest.param( + UnspecifiedBattery, + BatteryType.UNSPECIFIED, + battery_pb2.BATTERY_TYPE_UNSPECIFIED, + ["battery type is unspecified"], + id="UNSPECIFIED", + ), + pytest.param( + UnrecognizedBattery, + 999, + 999, + ["battery type 999 is unrecognized"], + id="UNRECOGNIZED", + ), + ], +) +def test_battery( + default_component_base_data: ComponentBaseData, + battery_class: type[Battery], + battery_type: BatteryType | int, + pb_battery_type: int, + expected_major_issues: list[str], +) -> None: + """Test battery component.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace(category=ComponentCategory.BATTERY) + proto = base_data_as_proto(base_data) + proto.category_type.battery.type = pb_battery_type # type: ignore[assignment] + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + assert major_issues == expected_major_issues + assert not minor_issues + assert isinstance(component, Battery) + assert isinstance(component, battery_class) + assert_base_data(base_data, component) + assert component.type == battery_type + + +@pytest.mark.parametrize( + "ev_charger_class, ev_charger_type, pb_ev_charger_type, expected_major_issues", + [ + pytest.param( + AcEvCharger, + EvChargerType.AC, + ev_charger_pb2.EV_CHARGER_TYPE_AC, + [], + id="AC", + ), + pytest.param( + DcEvCharger, + EvChargerType.DC, + ev_charger_pb2.EV_CHARGER_TYPE_DC, + [], + id="DC", + ), + pytest.param( + HybridEvCharger, + EvChargerType.HYBRID, + ev_charger_pb2.EV_CHARGER_TYPE_HYBRID, + [], + id="HYBRID", + ), + pytest.param( + UnspecifiedEvCharger, + EvChargerType.UNSPECIFIED, + ev_charger_pb2.EV_CHARGER_TYPE_UNSPECIFIED, + ["ev_charger type is unspecified"], + id="UNSPECIFIED", + ), + pytest.param( + UnrecognizedEvCharger, + 999, + 999, + ["ev_charger type 999 is unrecognized"], + id="UNRECOGNIZED", + ), + ], +) +def test_ev_charger( + default_component_base_data: ComponentBaseData, + ev_charger_class: type[EvCharger], + ev_charger_type: EvChargerType | int, + pb_ev_charger_type: int, + expected_major_issues: list[str], +) -> None: + """Test EV Charger component.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace( + category=ComponentCategory.EV_CHARGER + ) + proto = base_data_as_proto(base_data) + proto.category_type.ev_charger.type = pb_ev_charger_type # type: ignore[assignment] + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + assert major_issues == expected_major_issues + assert not minor_issues + assert isinstance(component, EvCharger) + assert isinstance(component, ev_charger_class) + assert_base_data(base_data, component) + assert component.type == ev_charger_type + + +@pytest.mark.parametrize( + "inverter_class, inverter_type, pb_inverter_type, expected_major_issues", + [ + pytest.param( + BatteryInverter, + InverterType.BATTERY, + inverter_pb2.INVERTER_TYPE_BATTERY, + [], + id="BATTERY", + ), + pytest.param( + SolarInverter, + InverterType.SOLAR, + inverter_pb2.INVERTER_TYPE_SOLAR, + [], + id="SOLAR", + ), + pytest.param( + HybridInverter, + InverterType.HYBRID, + inverter_pb2.INVERTER_TYPE_HYBRID, + [], + id="HYBRID", + ), + pytest.param( + UnspecifiedInverter, + InverterType.UNSPECIFIED, + inverter_pb2.INVERTER_TYPE_UNSPECIFIED, + ["inverter type is unspecified"], + id="UNSPECIFIED", + ), + pytest.param( + UnrecognizedInverter, + 999, + 999, + ["inverter type 999 is unrecognized"], + id="UNRECOGNIZED", + ), + ], +) +def test_inverter( + default_component_base_data: ComponentBaseData, + inverter_class: type[Inverter], + inverter_type: InverterType | int, + pb_inverter_type: int, + expected_major_issues: list[str], +) -> None: + """Test inverter component.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + base_data = default_component_base_data._replace( + category=ComponentCategory.INVERTER + ) + proto = base_data_as_proto(base_data) + proto.category_type.inverter.type = pb_inverter_type # type: ignore[assignment] + + component = component_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + assert major_issues == expected_major_issues + assert not minor_issues + assert isinstance(component, Inverter) + assert isinstance(component, inverter_class) + assert_base_data(base_data, component) + assert component.type == inverter_type From dad857498aed5cff6e8efbc937d587d1940167d7 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 23 May 2025 16:48:45 +0200 Subject: [PATCH 12/12] Implement `ListComponents` Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 6 +- src/frequenz/client/microgrid/_client.py | 72 ++++ .../diverse_component_types_case.py | 343 ++++++++++++++++++ .../list_components/empty_case.py | 25 ++ .../list_components/error_case.py | 31 ++ .../filter_by_categories_case.py | 69 ++++ .../filter_by_component_ids_case.py | 60 +++ .../filter_by_component_objects_case.py | 63 ++++ .../filter_by_integer_category_case.py | 46 +++ .../list_components/filter_combined_case.py | 54 +++ tests/test_client.py | 13 + 11 files changed, 780 insertions(+), 2 deletions(-) create mode 100644 tests/client_test_cases/list_components/diverse_component_types_case.py create mode 100644 tests/client_test_cases/list_components/empty_case.py create mode 100644 tests/client_test_cases/list_components/error_case.py create mode 100644 tests/client_test_cases/list_components/filter_by_categories_case.py create mode 100644 tests/client_test_cases/list_components/filter_by_component_ids_case.py create mode 100644 tests/client_test_cases/list_components/filter_by_component_objects_case.py create mode 100644 tests/client_test_cases/list_components/filter_by_integer_category_case.py create mode 100644 tests/client_test_cases/list_components/filter_combined_case.py diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 80e5f0cb..2dcb1769 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,11 +2,13 @@ ## Summary - +This release is a major breaking change, as we jump to the API specification version 0.17.x, which introduces big and fundamental breaking changes. This also starts using the `v1` namespace in `frequenz-api-common`, which also introduces major breaking changes. It would be very hard to detail all the API changes here, please refer to the [Microgrid API releases](https://github.com/frequenz-floss/frequenz-api-microgrid/releases) and [Common API releases](https://github.com/frequenz-floss/frequenz-api-common/releases). ## Upgrading - +- `MicrogridApiClient`: + + * The client now follows the v0.17 API names, so most methods changed names and signatures. ## New Features diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index fa67b438..a7fa3916 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -15,6 +15,7 @@ from typing import Any, assert_never from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2 +from frequenz.api.common.v1.microgrid.components import components_pb2 from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc from frequenz.client.base import channel, client, conversion, retry, streaming from frequenz.client.common.microgrid.components import ComponentId @@ -24,7 +25,10 @@ from ._exception import ClientNotConnected from ._microgrid_info import MicrogridInfo from ._microgrid_info_proto import microgrid_info_from_proto +from .component._category import ComponentCategory from .component._component import Component +from .component._component_proto import component_from_proto +from .component._types import ComponentTypes from .metrics._bounds import Bounds from .metrics._metric import Metric @@ -162,6 +166,61 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly) return microgrid_info_from_proto(microgrid.microgrid) + async def list_components( # noqa: DOC502 (raises ApiClientError indirectly) + self, + *, + components: Iterable[ComponentId | Component] = (), + categories: Iterable[ComponentCategory | int] = (), + ) -> Iterable[ComponentTypes]: + """Fetch all the components present in the local microgrid. + + Electrical components are a part of a microgrid's electrical infrastructure + are can be connected to each other to form an electrical circuit, which can + then be represented as a graph. + + If provided, the filters for component and categories have an `AND` + relationship with one another, meaning that they are applied serially, + but the elements within a single filter list have an `OR` relationship with + each other. + + Example: + If `ids = {1, 2, 3}`, and `categories = {ComponentCategory.INVERTER, + ComponentCategory.BATTERY}`, then the results will consist of elements that + have: + + * The IDs 1, `OR` 2, `OR` 3; `AND` + * Are of the categories `ComponentCategory.INVERTER` `OR` + `ComponentCategory.BATTERY`. + + If a filter list is empty, then that filter is not applied. + + Args: + components: The components to fetch. See the method description for details. + categories: The categories of the components to fetch. See the method + description for details. + + Returns: + Iterator whose elements are all the components in the local microgrid. + + Raises: + ApiClientError: If the are any errors communicating with the Microgrid API, + most likely a subclass of + [GrpcError][frequenz.client.microgrid.GrpcError]. + """ + component_list = await client.call_stub_method( + self, + lambda: self.stub.ListComponents( + microgrid_pb2.ListComponentsRequest( + component_ids=map(_get_component_id, components), + categories=map(_get_category_value, categories), + ), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, + ), + method_name="ListComponents", + ) + + return map(component_from_proto, component_list.components) + async def set_component_power_active( # noqa: DOC502 (raises ApiClientError indirectly) self, component: ComponentId | Component, @@ -456,6 +515,19 @@ def _get_metric_value(metric: Metric | int) -> metric_sample_pb2.Metric.ValueTyp assert_never(unexpected) +def _get_category_value( + category: ComponentCategory | int, +) -> components_pb2.ComponentCategory.ValueType: + """Get the category value from a component or component category.""" + match category: + case ComponentCategory(): + return components_pb2.ComponentCategory.ValueType(category.value) + case int(): + return components_pb2.ComponentCategory.ValueType(category) + case unexpected: + assert_never(unexpected) + + def _delta_to_seconds(delta: timedelta | None) -> int | None: """Convert a `timedelta` to seconds (or `None` if `None`).""" return round(delta.total_seconds()) if delta is not None else None diff --git a/tests/client_test_cases/list_components/diverse_component_types_case.py b/tests/client_test_cases/list_components/diverse_component_types_case.py new file mode 100644 index 00000000..ae554445 --- /dev/null +++ b/tests/client_test_cases/list_components/diverse_component_types_case.py @@ -0,0 +1,343 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test list_components with various component types.""" + +from typing import Any + +from frequenz.api.common.v1.microgrid.components import ( + battery_pb2, + components_pb2, + ev_charger_pb2, + fuse_pb2, + grid_pb2, + inverter_pb2, +) +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + AcEvCharger, + Battery, + BatteryInverter, + BatteryType, + Chp, + ComponentCategory, + Converter, + CryptoMiner, + DcEvCharger, + Electrolyzer, + EvCharger, + EvChargerType, + Fuse, + GridConnectionPoint, + Hvac, + HybridEvCharger, + HybridInverter, + Inverter, + InverterType, + LiIonBattery, + Meter, + MismatchedCategoryComponent, + NaIonBattery, + Precharger, + Relay, + SolarInverter, + UnrecognizedBattery, + UnrecognizedComponent, + UnrecognizedEvCharger, + UnrecognizedInverter, + UnspecifiedBattery, + UnspecifiedComponent, + UnspecifiedEvCharger, + UnspecifiedInverter, + VoltageTransformer, +) + +# No client_args or client_kwargs needed for this call + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListComponentsRequest(component_ids=[], categories=[]), + timeout=60.0, + ) + + +grpc_response = microgrid_pb2.ListComponentsResponse( + components=[ + components_pb2.Component( + id=1, + microgrid_id=1, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_GRID, + category_type=components_pb2.ComponentCategoryMetadataVariant( + grid=grid_pb2.GridConnectionPoint(rated_fuse_current=10_000) + ), + ), + components_pb2.Component( + id=2, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + inverter=inverter_pb2.Inverter( + type=inverter_pb2.InverterType.INVERTER_TYPE_SOLAR + ) + ), + ), + components_pb2.Component( + id=3, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + battery=battery_pb2.Battery( + type=battery_pb2.BatteryType.BATTERY_TYPE_LI_ION + ) + ), + ), + components_pb2.Component( + id=4, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_CONVERTER + ), + components_pb2.Component( + id=5, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER + ), + components_pb2.Component( + id=6, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + ev_charger=ev_charger_pb2.EvCharger( + type=ev_charger_pb2.EvChargerType.EV_CHARGER_TYPE_AC + ) + ), + ), + components_pb2.Component( + id=7, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_FUSE, + category_type=components_pb2.ComponentCategoryMetadataVariant( + fuse=fuse_pb2.Fuse(rated_current=50) + ), + ), + components_pb2.Component( + id=8, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_HVAC + ), + # Additional battery types + components_pb2.Component( + id=9, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + battery=battery_pb2.Battery( + type=battery_pb2.BatteryType.BATTERY_TYPE_UNSPECIFIED + ) + ), + ), + components_pb2.Component( + id=10, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + battery=battery_pb2.Battery( + type=battery_pb2.BatteryType.BATTERY_TYPE_NA_ION + ) + ), + ), + components_pb2.Component( + id=11, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + battery=battery_pb2.Battery( + type=666, # type: ignore[arg-type] + ) + ), + ), + # Additional inverter types + components_pb2.Component( + id=12, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + inverter=inverter_pb2.Inverter( + type=inverter_pb2.InverterType.INVERTER_TYPE_UNSPECIFIED + ) + ), + ), + components_pb2.Component( + id=13, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + inverter=inverter_pb2.Inverter( + type=inverter_pb2.InverterType.INVERTER_TYPE_BATTERY + ) + ), + ), + components_pb2.Component( + id=14, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + inverter=inverter_pb2.Inverter( + type=inverter_pb2.InverterType.INVERTER_TYPE_HYBRID + ) + ), + ), + components_pb2.Component( + id=15, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + inverter=inverter_pb2.Inverter( + type=777, # type: ignore[arg-type] + ) + ), + ), + # Additional EV charger types + components_pb2.Component( + id=16, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + ev_charger=ev_charger_pb2.EvCharger( + type=ev_charger_pb2.EvChargerType.EV_CHARGER_TYPE_UNSPECIFIED + ) + ), + ), + components_pb2.Component( + id=17, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + ev_charger=ev_charger_pb2.EvCharger( + type=ev_charger_pb2.EvChargerType.EV_CHARGER_TYPE_DC + ) + ), + ), + components_pb2.Component( + id=18, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + ev_charger=ev_charger_pb2.EvCharger( + type=ev_charger_pb2.EvChargerType.EV_CHARGER_TYPE_HYBRID + ) + ), + ), + components_pb2.Component( + id=19, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + ev_charger=ev_charger_pb2.EvCharger( + type=888, # type: ignore[arg-type] + ) + ), + ), + # Additional component categories + components_pb2.Component( + id=20, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_CHP + ), + components_pb2.Component( + id=21, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_CRYPTO_MINER, + ), + components_pb2.Component( + id=22, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_ELECTROLYZER, + ), + components_pb2.Component( + id=23, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_PRECHARGER, + ), + components_pb2.Component( + id=24, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_RELAY + ), + components_pb2.Component( + id=25, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_VOLTAGE_TRANSFORMER, + ), + # Problematic components + components_pb2.Component( + id=26, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED, + ), + components_pb2.Component( + id=27, + category=999, # type: ignore[arg-type] + ), + components_pb2.Component( + id=28, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + # Mismatched: battery category with inverter metadata + inverter=inverter_pb2.Inverter( + type=inverter_pb2.InverterType.INVERTER_TYPE_SOLAR + ) + ), + ), + ] +) + + +def assert_client_result(result: Any) -> None: + """Assert that the client result matches the expected components.""" + components = list(result) + assert components == [ + GridConnectionPoint( + id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=10_000 + ), + SolarInverter(id=ComponentId(2), microgrid_id=MicrogridId(0)), + LiIonBattery(id=ComponentId(3), microgrid_id=MicrogridId(0)), + Converter(id=ComponentId(4), microgrid_id=MicrogridId(0)), + Meter(id=ComponentId(5), microgrid_id=MicrogridId(0)), + AcEvCharger(id=ComponentId(6), microgrid_id=MicrogridId(0)), + Fuse(id=ComponentId(7), microgrid_id=MicrogridId(0), rated_current=50), + Hvac(id=ComponentId(8), microgrid_id=MicrogridId(0)), + # Additional battery types + UnspecifiedBattery(id=ComponentId(9), microgrid_id=MicrogridId(0)), + NaIonBattery(id=ComponentId(10), microgrid_id=MicrogridId(0)), + UnrecognizedBattery(id=ComponentId(11), microgrid_id=MicrogridId(0), type=666), + # Additional inverter types + UnspecifiedInverter(id=ComponentId(12), microgrid_id=MicrogridId(0)), + BatteryInverter(id=ComponentId(13), microgrid_id=MicrogridId(0)), + HybridInverter(id=ComponentId(14), microgrid_id=MicrogridId(0)), + UnrecognizedInverter( + id=ComponentId(15), microgrid_id=MicrogridId(0), type=777 + ), # Default type value + # Additional EV charger types + UnspecifiedEvCharger(id=ComponentId(16), microgrid_id=MicrogridId(0)), + DcEvCharger(id=ComponentId(17), microgrid_id=MicrogridId(0)), + HybridEvCharger(id=ComponentId(18), microgrid_id=MicrogridId(0)), + UnrecognizedEvCharger( + id=ComponentId(19), microgrid_id=MicrogridId(0), type=888 + ), # Default type value + # Additional component categories + Chp(id=ComponentId(20), microgrid_id=MicrogridId(0)), + CryptoMiner(id=ComponentId(21), microgrid_id=MicrogridId(0)), + Electrolyzer(id=ComponentId(22), microgrid_id=MicrogridId(0)), + Precharger(id=ComponentId(23), microgrid_id=MicrogridId(0)), + Relay(id=ComponentId(24), microgrid_id=MicrogridId(0)), + VoltageTransformer( + id=ComponentId(25), + microgrid_id=MicrogridId(0), + primary_voltage=0.0, + secondary_voltage=0.0, + ), + # Problematic components + UnspecifiedComponent(id=ComponentId(26), microgrid_id=MicrogridId(0)), + UnrecognizedComponent( + id=ComponentId(27), microgrid_id=MicrogridId(0), category=999 + ), + MismatchedCategoryComponent( + id=ComponentId(28), + microgrid_id=MicrogridId(0), + category=ComponentCategory.BATTERY, + category_specific_metadata={ + "type": "INVERTER_TYPE_SOLAR", + }, + ), + ] + + # Make sure we are testing all known categories and types + assert set(ComponentCategory) == { + component.category for component in components + } - {999} + assert set(BatteryType) == { + battery.type for battery in components if isinstance(battery, Battery) + } - {666} + assert set(InverterType) == { + inverter.type for inverter in components if isinstance(inverter, Inverter) + } - {777} + assert set(EvChargerType) == { + ev_charger.type + for ev_charger in components + if isinstance(ev_charger, EvCharger) + } - {888} diff --git a/tests/client_test_cases/list_components/empty_case.py b/tests/client_test_cases/list_components/empty_case.py new file mode 100644 index 00000000..a5bfe70a --- /dev/null +++ b/tests/client_test_cases/list_components/empty_case.py @@ -0,0 +1,25 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test list_components with no components.""" + +from typing import Any + +from frequenz.api.microgrid.v1 import microgrid_pb2 + +# No client_args or client_kwargs needed for this call + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListComponentsRequest(component_ids=[], categories=[]), + timeout=60.0, + ) + + +grpc_response = microgrid_pb2.ListComponentsResponse(components=[]) + + +def assert_client_result(result: Any) -> None: # noqa: D103 + assert not list(result) diff --git a/tests/client_test_cases/list_components/error_case.py b/tests/client_test_cases/list_components/error_case.py new file mode 100644 index 00000000..b1b28f0a --- /dev/null +++ b/tests/client_test_cases/list_components/error_case.py @@ -0,0 +1,31 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test list_components with error.""" + +from typing import Any + +from frequenz.api.microgrid.v1 import microgrid_pb2 +from grpc import StatusCode + +from frequenz.client.microgrid import PermissionDenied +from tests.util import make_grpc_error + +# No client_args or client_kwargs needed for this call + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListComponentsRequest(component_ids=[], categories=[]), + timeout=60.0, + ) + + +grpc_response = make_grpc_error(StatusCode.PERMISSION_DENIED) + + +def assert_client_exception(exception: Exception) -> None: + """Assert that the client exception matches the expected error.""" + assert isinstance(exception, PermissionDenied) + assert exception.grpc_error == grpc_response diff --git a/tests/client_test_cases/list_components/filter_by_categories_case.py b/tests/client_test_cases/list_components/filter_by_categories_case.py new file mode 100644 index 00000000..f30a412c --- /dev/null +++ b/tests/client_test_cases/list_components/filter_by_categories_case.py @@ -0,0 +1,69 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test list_components with category filtering.""" + +from typing import Any + +from frequenz.api.common.v1.microgrid.components import ( + battery_pb2, + components_pb2, + inverter_pb2, +) +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ( + ComponentCategory, + LiIonBattery, + SolarInverter, +) + +client_kwargs = {"categories": [ComponentCategory.BATTERY, ComponentCategory.INVERTER]} + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListComponentsRequest( + component_ids=[], + categories=[ + components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + ], + ), + timeout=60.0, + ) + + +grpc_response = microgrid_pb2.ListComponentsResponse( + components=[ + components_pb2.Component( + id=2, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, + category_type=components_pb2.ComponentCategoryMetadataVariant( + inverter=inverter_pb2.Inverter( + type=inverter_pb2.InverterType.INVERTER_TYPE_SOLAR + ) + ), + ), + components_pb2.Component( + id=3, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + battery=battery_pb2.Battery( + type=battery_pb2.BatteryType.BATTERY_TYPE_LI_ION + ) + ), + ), + ] +) + + +def assert_client_result(result: Any) -> None: + """Assert that the client result matches the expected filtered components.""" + assert list(result) == [ + SolarInverter(id=ComponentId(2), microgrid_id=MicrogridId(0)), + LiIonBattery(id=ComponentId(3), microgrid_id=MicrogridId(0)), + ] diff --git a/tests/client_test_cases/list_components/filter_by_component_ids_case.py b/tests/client_test_cases/list_components/filter_by_component_ids_case.py new file mode 100644 index 00000000..d0a3f194 --- /dev/null +++ b/tests/client_test_cases/list_components/filter_by_component_ids_case.py @@ -0,0 +1,60 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test list_components with component ID filtering.""" + +from typing import Any + +from frequenz.api.common.v1.microgrid.components import ( + battery_pb2, + components_pb2, + grid_pb2, +) +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import GridConnectionPoint, LiIonBattery + +client_kwargs = {"components": [ComponentId(1), ComponentId(3)]} + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListComponentsRequest(component_ids=[1, 3], categories=[]), + timeout=60.0, + ) + + +grpc_response = microgrid_pb2.ListComponentsResponse( + components=[ + components_pb2.Component( + id=1, + microgrid_id=1, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_GRID, + category_type=components_pb2.ComponentCategoryMetadataVariant( + grid=grid_pb2.GridConnectionPoint(rated_fuse_current=10_000) + ), + ), + components_pb2.Component( + id=3, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + battery=battery_pb2.Battery( + type=battery_pb2.BatteryType.BATTERY_TYPE_LI_ION + ) + ), + ), + ] +) + + +def assert_client_result(result: Any) -> None: + """Assert that the client result matches the expected filtered components.""" + assert list(result) == [ + GridConnectionPoint( + id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=10_000 + ), + LiIonBattery(id=ComponentId(3), microgrid_id=MicrogridId(0)), + ] diff --git a/tests/client_test_cases/list_components/filter_by_component_objects_case.py b/tests/client_test_cases/list_components/filter_by_component_objects_case.py new file mode 100644 index 00000000..0209fe8c --- /dev/null +++ b/tests/client_test_cases/list_components/filter_by_component_objects_case.py @@ -0,0 +1,63 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test list_components with Component objects as filters.""" + +from typing import Any + +from frequenz.api.common.v1.microgrid.components import ( + battery_pb2, + components_pb2, + grid_pb2, +) +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import GridConnectionPoint, LiIonBattery + +grid_component = GridConnectionPoint( + id=ComponentId(1), microgrid_id=MicrogridId(1), rated_fuse_current=10_000 +) +battery_component = LiIonBattery(id=ComponentId(3), microgrid_id=MicrogridId(1)) + +client_kwargs = {"components": [grid_component, battery_component]} + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListComponentsRequest(component_ids=[1, 3], categories=[]), + timeout=60.0, + ) + + +grpc_response = microgrid_pb2.ListComponentsResponse( + components=[ + components_pb2.Component( + id=int(grid_component.id), + microgrid_id=int(grid_component.microgrid_id), + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_GRID, + category_type=components_pb2.ComponentCategoryMetadataVariant( + grid=grid_pb2.GridConnectionPoint( + rated_fuse_current=grid_component.rated_fuse_current + ) + ), + ), + components_pb2.Component( + id=int(battery_component.id), + microgrid_id=int(battery_component.microgrid_id), + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + battery=battery_pb2.Battery( + type=battery_pb2.BatteryType.BATTERY_TYPE_LI_ION + ) + ), + ), + ] +) + + +def assert_client_result(result: Any) -> None: + """Assert that the client result matches the expected filtered components.""" + assert list(result) == [grid_component, battery_component] diff --git a/tests/client_test_cases/list_components/filter_by_integer_category_case.py b/tests/client_test_cases/list_components/filter_by_integer_category_case.py new file mode 100644 index 00000000..2b95ccdd --- /dev/null +++ b/tests/client_test_cases/list_components/filter_by_integer_category_case.py @@ -0,0 +1,46 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test list_components with integer category filtering.""" + +from typing import Any + +from frequenz.api.common.v1.microgrid.components import components_pb2 +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import UnrecognizedComponent + +client_kwargs = {"categories": [999]} + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListComponentsRequest( + component_ids=[], + categories=[999], # type: ignore[list-item] + ), + timeout=60.0, + ) + + +grpc_response = microgrid_pb2.ListComponentsResponse( + components=[ + components_pb2.Component( + id=4, + microgrid_id=1, + category=999, # type: ignore[arg-type] + ), + ] +) + + +def assert_client_result(result: Any) -> None: + """Assert that the client result matches the expected filtered components.""" + assert list(result) == [ + UnrecognizedComponent( + id=ComponentId(4), microgrid_id=MicrogridId(1), category=999 + ) + ] diff --git a/tests/client_test_cases/list_components/filter_combined_case.py b/tests/client_test_cases/list_components/filter_combined_case.py new file mode 100644 index 00000000..908d01d9 --- /dev/null +++ b/tests/client_test_cases/list_components/filter_combined_case.py @@ -0,0 +1,54 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test list_components with combined component ID and category filtering.""" + +from typing import Any + +from frequenz.api.common.v1.microgrid.components import battery_pb2, components_pb2 +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.component import ComponentCategory, LiIonBattery + +battery = LiIonBattery(id=ComponentId(3), microgrid_id=MicrogridId(0)) + +client_kwargs = { + "components": [battery, ComponentId(5)], + "categories": [ComponentCategory.BATTERY, 999], +} + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListComponentsRequest( + component_ids=[3, 5], + categories=[ + components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + 999, # type: ignore[list-item] + ], + ), + timeout=60.0, + ) + + +grpc_response = microgrid_pb2.ListComponentsResponse( + components=[ + components_pb2.Component( + id=3, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, + category_type=components_pb2.ComponentCategoryMetadataVariant( + battery=battery_pb2.Battery( + type=battery_pb2.BatteryType.BATTERY_TYPE_LI_ION + ) + ), + ), + ] +) + + +def assert_client_result(result: Any) -> None: + """Assert that the client result matches the expected filtered components.""" + assert list(result) == [battery] diff --git a/tests/test_client.py b/tests/test_client.py index c242ad1d..81ce4713 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -98,6 +98,19 @@ async def test_get_microgrid_info( await spec.test_unary_unary_call(client, "GetMicrogridMetadata") +@pytest.mark.asyncio +@pytest.mark.parametrize( + "spec", + get_test_specs("list_components", tests_dir=TESTS_DIR), + ids=str, +) +async def test_list_components( + client: MicrogridApiClient, spec: ApiClientTestCaseSpec +) -> None: + """Test list_components method.""" + await spec.test_unary_unary_call(client, "ListComponents") + + @pytest.mark.asyncio @pytest.mark.parametrize( "spec",