diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 09460d2..1fa5be8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,7 +6,12 @@ ## Upgrading - +* `TargetComponents` was reworked. It now is a type alias for `TargetIds | TargetCategories`: + * `TargetIds` can be used to specify one or more specific target IDs: + * `TargetIds(1, 2, 3)` or + * `TargetIds(ComponentIds(1), ComponentIds(2), ComponentIds(3))` + * `TargetCategories` can be used to specify one or more target categories: + * `TargetCategories(ComponentCategory.BATTERY, ComponentCategory.SOLAR)` ## New Features @@ -14,6 +19,21 @@ * Every call now has a default timeout of 60 seconds, streams terminate after five minutes. This can be influenced by the two new parameters for`DispatchApiClient.__init__()`: * `default_timeout: timedelta` (default: 60 seconds) * `stream_timeout: timedelta` (default: 5 minutes) +* With the new `TargetCategory` class (providing `.category` and `.type`) we can now specify subtypes of the categories: + * `ComponentCategory.BATTERY` uses `BatteryType` with possible values: `LI_ION`, `NA_ION` + * `ComponentCategory.INVERTER` uses `InverterType` with possible values: `BATTERY`, `SOLAR`, `HYBRID` + * `ComponentCategory.EV_CHARGER` uses `EvChargerType`: with possible values `AC`, `DC`, `HYBRID` + * A few examples on how to use the new `TargetCategory`: + * `TargetCategory(BatteryType.LI_ION)` + * `category` is `ComponentCategory.BATTERY` + * `type` is `BatteryType.LI_ION` + * `TargetCategory(ComponentCategory.BATTERY)` + * `category` is `ComponentCategory.BATTERY` + * `type` is `None` + * `TargetCategories(InverterType.SOLAR)` + * `category` is `ComponentCategory.INVERTER` + * `type` is `InverterType.SOLAR` + ## Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index e4e3624..6b6eef2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires = [ "setuptools == 80.4.0", "setuptools_scm[toml] == 8.3.1", - "frequenz-repo-config[lib] == 0.13.3", + "frequenz-repo-config[lib] == 0.13.4", ] build-backend = "setuptools.build_meta" @@ -37,7 +37,7 @@ classifiers = [ requires-python = ">= 3.11, < 4" dependencies = [ "typing-extensions >= 4.6.1, < 5", - "frequenz-api-dispatch == 1.0.0-rc1", + "frequenz-api-dispatch == 1.0.0-rc2", "frequenz-client-base >= 0.8.0, < 0.12.0", "frequenz-client-common >= 0.1.0, < 0.4.0", "grpcio >= 1.70.0, < 2", @@ -74,10 +74,11 @@ dev-mkdocs = [ "mike == 2.1.3", "mkdocs-gen-files == 0.5.0", "mkdocs-literate-nav == 0.6.2", + "frequenz-api-dispatch == 1.0.0-rc2", "mkdocs-macros-plugin == 1.3.7", "mkdocs-material == 9.6.12", "mkdocstrings[python] == 0.29.1", - "frequenz-repo-config[lib] == 0.13.3", + "frequenz-repo-config[lib] == 0.13.4", ] dev-mypy = [ "mypy == 1.15.0", @@ -88,16 +89,16 @@ dev-mypy = [ "types-protobuf == 5.29.1.20250403", "types-python-dateutil == 2.9.0.20241206", ] -dev-noxfile = ["nox == 2025.5.1", "frequenz-repo-config[lib] == 0.13.3"] +dev-noxfile = ["nox == 2025.5.1", "frequenz-repo-config[lib] == 0.13.4"] dev-pylint = [ "pylint == 3.3.6", # For checking the noxfile, docs/ script, and tests "frequenz-client-dispatch[cli,dev-mkdocs,dev-noxfile,dev-pytest]", - "frequenz-api-dispatch == 1.0.0-rc1", + "frequenz-api-dispatch == 1.0.0-rc2", ] dev-pytest = [ "pytest == 8.3.5", - "frequenz-repo-config[extra-lint-examples] == 0.13.3", + "frequenz-repo-config[extra-lint-examples] == 0.13.4", "pytest-mock == 3.14.0", "pytest-asyncio == 0.26.0", "async-solipsism == 0.7", diff --git a/src/frequenz/client/dispatch/__main__.py b/src/frequenz/client/dispatch/__main__.py index 7ea36ea..1cefc89 100644 --- a/src/frequenz/client/dispatch/__main__.py +++ b/src/frequenz/client/dispatch/__main__.py @@ -86,7 +86,8 @@ def print_dispatch(dispatch: Dispatch) -> None: # Format the target if dispatch.target: if len(dispatch.target) == 1: - target_str: str = str(dispatch.target[0]) + (first_element,) = dispatch.target + target_str: str = str(first_element) else: target_str = ", ".join(str(s) for s in dispatch.target) else: diff --git a/src/frequenz/client/dispatch/_cli_types.py b/src/frequenz/client/dispatch/_cli_types.py index 40a899e..7371054 100644 --- a/src/frequenz/client/dispatch/_cli_types.py +++ b/src/frequenz/client/dispatch/_cli_types.py @@ -5,6 +5,7 @@ import json from datetime import datetime, timedelta, timezone +from itertools import chain from typing import Any, Literal, cast import asyncclick as click @@ -12,6 +13,14 @@ from tzlocal import get_localzone from frequenz.client.common.microgrid.components import ComponentCategory +from frequenz.client.dispatch.types import ( + BatteryType, + EvChargerType, + InverterType, + TargetCategories, + TargetComponents, + TargetIds, +) # Disable a false positive from pylint # pylint: disable=inconsistent-return-statements @@ -140,7 +149,7 @@ class TargetComponentParamType(click.ParamType): def convert( self, value: Any, param: click.Parameter | None, ctx: click.Context | None - ) -> list[ComponentCategory] | list[int]: + ) -> TargetIds | TargetCategories: """Convert the input value into a list of ComponentCategory or IDs. Args: @@ -149,9 +158,9 @@ def convert( ctx: The Click context object. Returns: - A list of component ids or component categories. + A list of targets, either as component IDs or component categories. """ - if isinstance(value, list): # Already a list + if isinstance(value, TargetComponents): return value values = value.split(",") @@ -162,20 +171,46 @@ def convert( error: Exception | None = None # Attempt to parse component ids try: - return [int(id) for id in values] + return TargetIds(*[int(id) for id in values]) except ValueError as e: error = e + def enum_from_str( + name: str, + ) -> InverterType | BatteryType | EvChargerType | ComponentCategory: + """Convert a string to an enum member.""" + name = name.strip().upper() + if name in ComponentCategory.__members__: + return ComponentCategory[name] + if name in InverterType.__members__: + return InverterType[name] + if name in BatteryType.__members__: + return BatteryType[name] + if name in EvChargerType.__members__: + return EvChargerType[name] + raise KeyError(f"Invalid target specification: {name}") + # Attempt to parse as component categories, trim whitespace try: - return [ComponentCategory[cat.strip().upper()] for cat in values] + return TargetCategories(*[enum_from_str(cat) for cat in values]) except KeyError as e: error = e + types_str = ", ".join( + [f"{type.name}" for type in chain(BatteryType, InverterType, EvChargerType)] + ) + self.fail( f'Invalid component category list or ID list: "{value}".\n' f'Error: "{error}"\n\n' - "Possible categories: BATTERY, GRID, METER, INVERTER, EV_CHARGER, CHP ", + "Valid formats:\n" + "- 1,2,3 # A list of component IDs\n" + "- METER,INVERTER # A list of component categories\n" + "- NA_ION,SOLAR # A list of component category types (category is derived)\n" + "Valid categories:\n" + f"{', '.join([cat.name for cat in ComponentCategory])}\n" + "Valid types:\n" + f"{types_str}\n", param, ctx, ) diff --git a/src/frequenz/client/dispatch/_client.py b/src/frequenz/client/dispatch/_client.py index 356189a..ab6fd8a 100644 --- a/src/frequenz/client/dispatch/_client.py +++ b/src/frequenz/client/dispatch/_client.py @@ -166,7 +166,7 @@ def to_interval( ) -> PBTimeIntervalFilter | None: return ( PBTimeIntervalFilter( - **{"from": to_timestamp(from_)}, to=to_timestamp(to) + from_time=to_timestamp(from_), to_time=to_timestamp(to) ) if from_ or to else None diff --git a/src/frequenz/client/dispatch/recurrence.py b/src/frequenz/client/dispatch/recurrence.py index 2df69a4..fef1993 100644 --- a/src/frequenz/client/dispatch/recurrence.py +++ b/src/frequenz/client/dispatch/recurrence.py @@ -88,8 +88,8 @@ def from_protobuf(cls, pb_criteria: PBRecurrenceRule.EndCriteria) -> "EndCriteri match pb_criteria.WhichOneof("count_or_until"): case "count": instance.count = pb_criteria.count - case "until": - instance.until = to_datetime(pb_criteria.until) + case "until_time": + instance.until = to_datetime(pb_criteria.until_time) return instance def to_protobuf(self) -> PBRecurrenceRule.EndCriteria: @@ -103,7 +103,7 @@ def to_protobuf(self) -> PBRecurrenceRule.EndCriteria: if self.count is not None: pb_criteria.count = self.count elif self.until is not None: - pb_criteria.until.CopyFrom(to_timestamp(self.until)) + pb_criteria.until_time.CopyFrom(to_timestamp(self.until)) return pb_criteria diff --git a/src/frequenz/client/dispatch/test/_service.py b/src/frequenz/client/dispatch/test/_service.py index 06146dd..7bc755e 100644 --- a/src/frequenz/client/dispatch/test/_service.py +++ b/src/frequenz/client/dispatch/test/_service.py @@ -188,20 +188,20 @@ def _filter_dispatch( if target != dispatch.target: return False if _filter.HasField("start_time_interval"): - if start_from := _filter.start_time_interval.__dict__["from"]: + if start_from := _filter.start_time_interval.from_time: if dispatch.start_time < _to_dt(start_from): return False - if start_to := _filter.start_time_interval.to: + if start_to := _filter.start_time_interval.to_time: if dispatch.start_time >= _to_dt(start_to): return False if _filter.HasField("end_time_interval"): - if end_from := _filter.end_time_interval.__dict__["from"]: + if end_from := _filter.end_time_interval.from_time: if ( dispatch.duration and dispatch.start_time + dispatch.duration < _to_dt(end_from) ): return False - if end_to := _filter.end_time_interval.to: + if end_to := _filter.end_time_interval.to_time: if ( dispatch.duration and dispatch.start_time + dispatch.duration >= _to_dt(end_to) diff --git a/src/frequenz/client/dispatch/test/generator.py b/src/frequenz/client/dispatch/test/generator.py index c00ab84..5bf054f 100644 --- a/src/frequenz/client/dispatch/test/generator.py +++ b/src/frequenz/client/dispatch/test/generator.py @@ -10,7 +10,16 @@ from .._internal_types import rounded_start_time from ..recurrence import EndCriteria, Frequency, RecurrenceRule, Weekday -from ..types import Dispatch +from ..types import ( + BatteryType, + Dispatch, + EvChargerType, + InverterType, + TargetCategories, + TargetCategory, + TargetComponents, + TargetIds, +) class DispatchGenerator: @@ -66,6 +75,27 @@ def generate_recurrence_rule(self) -> RecurrenceRule: ], ) + def generate_target_category_and_type(self) -> TargetCategory: + """Generate a random category and type. + + Returns: + a random category and type + """ + category = self._rng.choice(list(ComponentCategory)[1:]) + category_type: BatteryType | InverterType | EvChargerType | None = None + + match category: + case ComponentCategory.BATTERY: + category_type = self._rng.choice(list(BatteryType)[1:]) + case ComponentCategory.INVERTER: + category_type = self._rng.choice(list(InverterType)[1:]) + case ComponentCategory.EV_CHARGER: + category_type = self._rng.choice(list(EvChargerType)[1:]) + case _: + category_type = None + + return TargetCategory(category_type or category) + def generate_dispatch(self) -> Dispatch: """Generate a random dispatch instance. @@ -77,6 +107,20 @@ def generate_dispatch(self) -> Dispatch: self._rng.randint(0, 1000000), tz=timezone.utc ) + target_choices: list[TargetComponents] = [ + TargetIds( + *[self._rng.randint(1, 100) for _ in range(self._rng.randint(1, 10))] + ), + TargetCategories( + *[ + # Not yet used + # self.generate_target_category_and_type() + self._rng.choice(list(ComponentCategory)[1:]) + for _ in range(self._rng.randint(1, 10)) + ] + ), + ] + return Dispatch( id=self._last_id, create_time=create_time, @@ -92,18 +136,7 @@ def generate_dispatch(self) -> Dispatch: timedelta(seconds=self._rng.randint(0, 1000000)), ] ), - target=self._rng.choice( # type: ignore - [ - [ - self._rng.choice(list(ComponentCategory)[1:]) - for _ in range(self._rng.randint(1, 10)) - ], - [ - self._rng.randint(1, 100) - for _ in range(self._rng.randint(1, 10)) - ], - ] - ), + target=self._rng.choice(target_choices), active=self._rng.choice([True, False]), dry_run=self._rng.choice([True, False]), payload={ diff --git a/src/frequenz/client/dispatch/types.py b/src/frequenz/client/dispatch/types.py index 1f8cc6b..62222d3 100644 --- a/src/frequenz/client/dispatch/types.py +++ b/src/frequenz/client/dispatch/types.py @@ -6,8 +6,19 @@ from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from enum import IntEnum -from typing import Any, cast +from enum import Enum +from typing import Any, Self, SupportsInt, TypeAlias, cast + +# pylint: enable=no-name-in-module +from frequenz.api.common.v1.microgrid.components.battery_pb2 import ( + BatteryType as PBBatteryType, +) +from frequenz.api.common.v1.microgrid.components.ev_charger_pb2 import ( + EvChargerType as PBEvChargerType, +) +from frequenz.api.common.v1.microgrid.components.inverter_pb2 import ( + InverterType as PBInverterType, +) # pylint: disable=no-name-in-module from frequenz.api.dispatch.v1.dispatch_pb2 import Dispatch as PBDispatch @@ -21,16 +32,181 @@ from google.protobuf.struct_pb2 import Struct from frequenz.client.base.conversion import to_datetime, to_timestamp - -# pylint: enable=no-name-in-module from frequenz.client.common.microgrid.components import ComponentCategory from .recurrence import Frequency, RecurrenceRule, Weekday -TargetComponents = list[int] | list[ComponentCategory] -"""One or more target components specifying which components a dispatch targets. -It can be a list of component IDs or a list of categories. +class EvChargerType(Enum): + """Enum representing the type of EV charger.""" + + UNSPECIFIED = PBEvChargerType.EV_CHARGER_TYPE_UNSPECIFIED + """Unspecified type of EV charger.""" + + AC = PBEvChargerType.EV_CHARGER_TYPE_AC + """AC EV charger.""" + + DC = PBEvChargerType.EV_CHARGER_TYPE_DC + """DC EV charger.""" + + HYBRID = PBEvChargerType.EV_CHARGER_TYPE_HYBRID + """Hybrid EV charger.""" + + +class BatteryType(Enum): + """Enum representing the type of battery.""" + + UNSPECIFIED = PBBatteryType.BATTERY_TYPE_UNSPECIFIED + """Unspecified type of battery.""" + + LI_ION = PBBatteryType.BATTERY_TYPE_LI_ION + """Lithium-ion battery.""" + + NA_ION = PBBatteryType.BATTERY_TYPE_NA_ION + """Sodium-ion battery.""" + + +class InverterType(Enum): + """Enum representing the type of inverter.""" + + UNSPECIFIED = PBInverterType.INVERTER_TYPE_UNSPECIFIED + """Unspecified type of inverter.""" + + BATTERY = PBInverterType.INVERTER_TYPE_BATTERY + """Battery inverter.""" + + SOLAR = PBInverterType.INVERTER_TYPE_SOLAR + """Solar inverter.""" + + HYBRID = PBInverterType.INVERTER_TYPE_HYBRID + """Hybrid inverter.""" + + +@dataclass(frozen=True) +class TargetCategory: + """Represents a category and optionally a type.""" + + target: ComponentCategory | BatteryType | EvChargerType | InverterType + """The target category of the dispatch. + + Implicitly derived from the types. + """ + + @property + def category(self) -> ComponentCategory: + """Get the category of the target. + + Returns: + The category of the target. + """ + match self.target: + case ComponentCategory(): + return self.target + case BatteryType(): + return ComponentCategory.BATTERY + case EvChargerType(): + return ComponentCategory.EV_CHARGER + case InverterType(): + return ComponentCategory.INVERTER + + @property + def type(self) -> BatteryType | EvChargerType | InverterType | None: + """Get the type of the category. + + Returns: + The type of the category. + """ + match self.target: + case BatteryType() | EvChargerType() | InverterType(): + return self.target + case _: + return None + + +class TargetIds(frozenset[int]): + """A set of target component IDs. + + This is a frozen set, so it is immutable. + """ + + def __new__(cls, *ids: SupportsInt) -> Self: + """Create a new TargetIds instance. + + Args: + *ids: The target IDs to initialize. + + Returns: + A new TargetIds instance. + """ + # Convert all provided ids to integers before creating the frozenset + processed_ids = tuple(int(id_val) for id_val in ids) + return super().__new__(cls, processed_ids) + + +# Define the union of types that can be passed to TargetCategories constructor +TargetCategoryInputType = ( + TargetCategory | ComponentCategory | BatteryType | InverterType | EvChargerType +) +"""Type for the input to TargetCategories constructor.""" + + +class TargetCategories(frozenset[TargetCategory]): + """A set of target component categories and types. + + This is a frozen set, so it is immutable. + """ + + def __new__(cls, *categories_input: TargetCategoryInputType) -> Self: + """Create a new TargetCategories instance. + + Args: + *categories_input: TargetCategory instances or raw ComponentCategory/specific types + (BatteryType, InverterType, EvChargerType) to be wrapped. + + Returns: + A new TargetCategories instance. + + Raises: + TypeError: If an item in categories_input is not a TargetCategory + nor one of the wrappable types. + """ + processed_elements = [] + for item in categories_input: + if isinstance(item, TargetCategory): + processed_elements.append(item) + elif isinstance( + item, (ComponentCategory, BatteryType, InverterType, EvChargerType) + ): + # Wrap raw categories/types into TargetCategory instances + processed_elements.append(TargetCategory(target=item)) + else: + # This case should ideally be caught by static type checkers + # if call sites adhere to type hints. + raise TypeError( + f"Invalid type for TargetCategories constructor: {type(item)}. " + f"Expected TargetCategory, ComponentCategory, BatteryType, " + f"InverterType, or EvChargerType." + ) + # `super().__new__` for frozenset expects an iterable of elements for the set + return super().__new__(cls, processed_elements) + + def __repr__(self) -> str: + """Return an ordered string representation.""" + ordered = sorted(list(self), key=lambda cat: cat.target.value) + return str([cat.target.name for cat in ordered]) + + +TargetComponents: TypeAlias = TargetIds | TargetCategories +"""Target components. + +Can be one of the following: + +- A set of target component IDs (TargetIds) +- A set of target component categories with opt. types (TargetCategories) + +This is a frozen set, so it is immutable. +The target components are used to specify the components that a dispatch +should target. """ @@ -50,20 +226,49 @@ def _target_components_from_protobuf( """ match pb_target.WhichOneof("components"): case "component_ids": - id_list: list[int] = list(pb_target.component_ids.ids) - return id_list + return TargetIds(*pb_target.component_ids.ids) case "component_categories": - category_list: list[ComponentCategory] = list( - map( + return TargetCategories( + *map( ComponentCategory.from_proto, pb_target.component_categories.categories, ) ) - return category_list + case "component_categories_types": + return TargetCategories( + *map( + lambda cat_and_type: _extract_category_type(cat_and_type) + or ComponentCategory.from_proto(cat_and_type.category), + pb_target.component_categories_types.categories, + ) + ) case _: raise ValueError("Invalid target components") +def _extract_category_type( + cat_and_type: PBTargetComponents.CategoryAndType, +) -> BatteryType | EvChargerType | InverterType | None: + """Extract the category type from a protobuf CategoryAndType. + + Args: + cat_and_type: The protobuf CategoryAndType to extract from. + + Returns: + The extracted category type. + """ + match cat_and_type.WhichOneof("type"): + case "battery": + return BatteryType(cat_and_type.battery) + case "ev_charger": + return EvChargerType(cat_and_type.ev_charger) + case "inverter": + return InverterType(cat_and_type.inverter) + case _: + return None + + +# Old, soon deprecated way to specify categories def _target_components_to_protobuf( target: TargetComponents, ) -> PBTargetComponents: @@ -80,17 +285,54 @@ def _target_components_to_protobuf( """ pb_target = PBTargetComponents() match target: - case list(component_ids) if all(isinstance(id, int) for id in component_ids): - pb_target.component_ids.ids.extend(cast(list[int], component_ids)) - case list(categories) if all( - isinstance(cat, ComponentCategory) for cat in categories - ): + case TargetIds(component_ids): + pb_target.component_ids.ids.extend(component_ids) + case TargetCategories(categories): + # Old, soon deprecated way to specify categories pb_target.component_categories.categories.extend( map( - lambda cat: cat.to_proto(), - cast(list[ComponentCategory], categories), + lambda cat: cat.category.to_proto(), + categories, ) ) + + case _: + raise ValueError(f"Invalid target components: {target}") + return pb_target + + +# New, not yet supported way to specify categories and types +def _target_components_to_protobuf_new( + target: TargetComponents, +) -> PBTargetComponents: + """Convert target components to protobuf. + + Args: + target: The target components to convert. + + Raises: + ValueError: If the target components are invalid. + + Returns: + The converted protobuf target components. + """ + pb_target = PBTargetComponents() + match target: + case TargetIds(component_ids): + pb_target.component_ids.ids.extend(component_ids) + case TargetCategories(categories): + for category in categories: + pb_category = pb_target.component_categories_types.categories.add() + pb_category.category = category.category.to_proto() + + match category.type: + case BatteryType(): + pb_category.battery = category.type.value + case EvChargerType(): + pb_category.ev_charger = category.type.value + case InverterType(): + pb_category.inverter = category.type.value + case _: raise ValueError(f"Invalid target components: {target}") return pb_target @@ -295,7 +537,7 @@ def from_protobuf(cls, pb_object: PBDispatch) -> "Dispatch": id=pb_object.metadata.dispatch_id, type=pb_object.data.type, create_time=to_datetime(pb_object.metadata.create_time), - update_time=to_datetime(pb_object.metadata.modification_time), + update_time=to_datetime(pb_object.metadata.update_time), end_time=( to_datetime(pb_object.metadata.end_time) if pb_object.metadata.HasField("end_time") @@ -327,7 +569,7 @@ def to_protobuf(self) -> PBDispatch: metadata=DispatchMetadata( dispatch_id=self.id, create_time=to_timestamp(self.create_time), - modification_time=to_timestamp(self.update_time), + update_time=to_timestamp(self.update_time), end_time=( to_timestamp(self.end_time) if self.end_time is not None else None ), @@ -349,7 +591,7 @@ def to_protobuf(self) -> PBDispatch: ) -class Event(IntEnum): +class Event(Enum): """Enum representing the type of event that occurred during a dispatch operation.""" UNSPECIFIED = StreamMicrogridDispatchesResponse.Event.EVENT_UNSPECIFIED diff --git a/tests/test_cli.py b/tests/test_cli.py index 6be30d0..1321162 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -21,7 +21,12 @@ Weekday, ) from frequenz.client.dispatch.test.client import ALL_KEY, FakeClient -from frequenz.client.dispatch.types import Dispatch +from frequenz.client.dispatch.types import ( + Dispatch, + TargetCategories, + TargetComponents, + TargetIds, +) TEST_NOW = datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc) """Arbitrary time used as NOW for testing.""" @@ -68,7 +73,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={}, @@ -91,7 +96,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={}, @@ -113,7 +118,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={}, @@ -128,7 +133,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={}, @@ -156,7 +161,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={}, @@ -169,7 +174,7 @@ def mock_client(fake_client: FakeClient) -> Generator[None, None, None]: type="filtered", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=1800), - target=[3], + target=TargetIds(3), active=True, dry_run=False, payload={}, @@ -227,7 +232,7 @@ async def test_list_command( "test", timedelta(hours=1), timedelta(seconds=3600), - [ComponentCategory.BATTERY], + TargetCategories(ComponentCategory.BATTERY), {"active": False}, RecurrenceRule(), 0, @@ -247,7 +252,7 @@ async def test_list_command( "test", timedelta(hours=2), timedelta(seconds=3600), - [1, 2, 3], + TargetIds(1, 2, 3), {"dry_run": True}, RecurrenceRule(), 0, @@ -258,7 +263,7 @@ async def test_list_command( "", timedelta(), timedelta(), - [], + None, {}, RecurrenceRule(), 2, @@ -298,7 +303,7 @@ async def test_list_command( "test", timedelta(hours=1), timedelta(seconds=3600), - [ComponentCategory.CHP], + TargetCategories(ComponentCategory.CHP), {}, RecurrenceRule( frequency=Frequency.HOURLY, @@ -333,7 +338,7 @@ async def test_list_command( "test50", timedelta(hours=5), timedelta(seconds=3600), - [ComponentCategory.EV_CHARGER], + TargetCategories(ComponentCategory.EV_CHARGER), {}, RecurrenceRule( frequency=Frequency.DAILY, @@ -361,7 +366,7 @@ async def test_list_command( "test_start_immediately", "NOW", timedelta(seconds=3600), - [ComponentCategory.BATTERY], + TargetCategories(ComponentCategory.BATTERY), {}, RecurrenceRule(), 0, @@ -376,7 +381,7 @@ async def test_create_command( expected_type: str, expected_start_time_delta: timedelta | Literal["NOW"], expected_duration: timedelta, - expected_target: list[int] | list[ComponentCategory], + expected_target: TargetComponents | None, expected_options: dict[str, Any], expected_reccurence: RecurrenceRule | None, expected_return_code: int, @@ -401,8 +406,8 @@ async def test_create_command( ), ) - assert result.exit_code == expected_return_code assert "id" in result.output + assert result.exit_code == expected_return_code dispatches = fake_client.dispatches(expected_microgrid_id) @@ -446,7 +451,7 @@ async def test_create_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[ComponentCategory.BATTERY], + target=TargetCategories(ComponentCategory.BATTERY), active=True, dry_run=False, payload={}, @@ -470,7 +475,7 @@ async def test_create_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[ComponentCategory.BATTERY], + target=TargetCategories(ComponentCategory.BATTERY), active=True, dry_run=False, payload={}, @@ -496,7 +501,9 @@ async def test_create_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[ComponentCategory.BATTERY, ComponentCategory.EV_CHARGER], + target=TargetCategories( + ComponentCategory.BATTERY, ComponentCategory.EV_CHARGER + ), active=True, dry_run=False, payload={}, @@ -510,16 +517,14 @@ async def test_create_command( "BATTERY, EV_CHARGER, CHP", ], { - "target": [ + "target": TargetCategories( ComponentCategory.BATTERY, ComponentCategory.EV_CHARGER, ComponentCategory.CHP, - ], + ), }, 0, - "target=[,\n " - + ",\n " - + "]", + "target=['BATTERY', 'EV_CHARGER', 'CHP']", ), ( [ @@ -528,7 +533,7 @@ async def test_create_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[500, 501], + target=TargetIds(500, 501), active=True, dry_run=False, payload={}, @@ -558,7 +563,7 @@ async def test_create_command( '{"key": "value"}', ], { - "target": [400, 401], + "target": TargetIds(400, 401), "recurrence": RecurrenceRule( frequency=Frequency.DAILY, interval=5, @@ -574,7 +579,7 @@ async def test_create_command( "payload": {"key": "value"}, }, 0, - """ target=[400, 401], + """ target=TargetIds({400, 401}), active=True, dry_run=False, payload={'key': 'value'}, @@ -633,7 +638,7 @@ async def test_update_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={}, @@ -680,7 +685,7 @@ async def test_get_command( type="test", start_time=datetime(2023, 1, 1, 0, 0, 0), duration=timedelta(seconds=3600), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={}, diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index e771335..0d7e83d 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -11,7 +11,7 @@ from frequenz.client.common.microgrid.components import ComponentCategory from frequenz.client.dispatch.recurrence import Frequency, RecurrenceRule, Weekday -from frequenz.client.dispatch.types import Dispatch +from frequenz.client.dispatch.types import Dispatch, TargetCategories, TargetIds # Define a fixed current time for testing to avoid issues with datetime.now() CURRENT_TIME = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) @@ -25,7 +25,7 @@ def dispatch_base() -> Dispatch: type="TypeA", start_time=CURRENT_TIME, duration=timedelta(minutes=20), - target=[ComponentCategory.BATTERY], + target=TargetCategories(ComponentCategory.BATTERY), active=True, dry_run=False, payload={}, @@ -255,3 +255,14 @@ def test_dispatch_next_run_after( expected_next_run_after = None assert dispatch.next_run_after(after) == expected_next_run_after + + +def test_target_ids_from_cid() -> None: + """Test using TargetIDs with ComponentIds.""" + + class ComponentId(int): + """Mock ComponentId class for testing.""" + + target = TargetIds(ComponentId(1), ComponentId(2), ComponentId(3)) + + assert target == TargetIds(1, 2, 3) diff --git a/tests/test_proto.py b/tests/test_proto.py index 1a4f1e8..70a6542 100644 --- a/tests/test_proto.py +++ b/tests/test_proto.py @@ -14,23 +14,39 @@ Weekday, ) from frequenz.client.dispatch.types import ( + BatteryType, Dispatch, + EvChargerType, + InverterType, + TargetCategories, + TargetCategory, + TargetIds, _target_components_from_protobuf, - _target_components_to_protobuf, + _target_components_to_protobuf_new, ) +CaT = TargetCategory +"""Shortcut for the lazy.""" -def test_target_components() -> None: + +def test_target_components_new() -> None: """Test the target components.""" for components in ( - [1, 2, 3], - [10, 20, 30], - [ComponentCategory.BATTERY], - [ComponentCategory.GRID], - [ComponentCategory.METER], - [ComponentCategory.EV_CHARGER, ComponentCategory.BATTERY], + TargetIds(1, 2, 3), + TargetIds(10, 20, 30), + TargetCategories(ComponentCategory.BATTERY), + TargetCategories(ComponentCategory.GRID), + TargetCategories(ComponentCategory.METER), + TargetCategories(ComponentCategory.EV_CHARGER, ComponentCategory.BATTERY), + TargetCategories(TargetCategory(BatteryType.LI_ION)), + TargetCategories( + TargetCategory(BatteryType.NA_ION), + TargetCategory(InverterType.SOLAR), + TargetCategory(EvChargerType.AC), + TargetCategory(ComponentCategory.METER), + ), ): - protobuf = _target_components_to_protobuf(components) + protobuf = _target_components_to_protobuf_new(components) assert _target_components_from_protobuf(protobuf) == components @@ -99,7 +115,7 @@ def test_dispatch() -> None: start_time=datetime(2024, 10, 10, tzinfo=timezone.utc), end_time=datetime(2024, 10, 20, tzinfo=timezone.utc), duration=timedelta(days=10), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={"key": "value"}, @@ -120,7 +136,7 @@ def test_dispatch() -> None: start_time=datetime(2024, 11, 10, tzinfo=timezone.utc), end_time=datetime(2024, 11, 20, tzinfo=timezone.utc), duration=timedelta(seconds=20), - target=[ComponentCategory.BATTERY], + target=TargetCategories(ComponentCategory.BATTERY), active=False, dry_run=True, payload={"key": "value1"}, @@ -140,7 +156,7 @@ def test_dispatch() -> None: start_time=datetime(2024, 11, 10, tzinfo=timezone.utc), end_time=None, duration=timedelta(seconds=20), - target=[ComponentCategory.BATTERY], + target=TargetCategories(ComponentCategory.BATTERY), active=False, dry_run=True, payload={"key": "value1"}, @@ -163,7 +179,7 @@ def test_dispatch_create_request_with_no_recurrence() -> None: type="test", start_time=datetime(2024, 10, 10, tzinfo=timezone.utc), duration=timedelta(days=10), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={"key": "value"}, @@ -180,7 +196,7 @@ def test_dispatch_create_start_immediately() -> None: type="test", start_time="NOW", duration=timedelta(days=10), - target=[1, 2, 3], + target=TargetIds(1, 2, 3), active=True, dry_run=False, payload={"key": "value"},