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/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/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py index 9543e938..3b0b77f4 100644 --- a/src/frequenz/client/microgrid/component/__init__.py +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -3,12 +3,104 @@ """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 +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 +from ._inverter import ( + BatteryInverter, + HybridInverter, + Inverter, + InverterType, + SolarInverter, + UnrecognizedInverter, + UnspecifiedInverter, +) +from ._meter import Meter +from ._precharger import Precharger +from ._problematic import ( + MismatchedCategoryComponent, + ProblematicComponent, + UnrecognizedComponent, + UnspecifiedComponent, +) +from ._relay import Relay from ._status import ComponentStatus +from ._types import ( + ComponentTypes, + ProblematicComponentTypes, + UnrecognizedComponentTypes, + UnspecifiedComponentTypes, +) +from ._voltage_transformer import VoltageTransformer __all__ = [ + "AcEvCharger", + "Battery", + "BatteryInverter", + "BatteryType", + "BatteryTypes", + "Chp", "Component", "ComponentCategory", "ComponentStatus", + "ComponentTypes", + "Converter", + "CryptoMiner", + "DcEvCharger", + "Electrolyzer", + "EvCharger", + "EvChargerType", + "EvChargerTypes", + "Fuse", + "GridConnectionPoint", + "Hvac", + "HybridEvCharger", + "HybridInverter", + "Inverter", + "InverterType", + "LiIonBattery", + "Meter", + "MismatchedCategoryComponent", + "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/_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/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/_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/_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/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/_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/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/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/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/_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/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/_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/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/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.""" 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/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/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 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 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_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 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, + ) 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, + ) 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_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 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_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 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 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) 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", 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)