Skip to content

Commit 2b78a64

Browse files
authored
Allow targeting categories with subtypes (#168)
2 parents 1e05672 + 8f90639 commit 2b78a64

File tree

12 files changed

+461
-97
lines changed

12 files changed

+461
-97
lines changed

RELEASE_NOTES.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,34 @@
66

77
## Upgrading
88

9-
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
9+
* `TargetComponents` was reworked. It now is a type alias for `TargetIds | TargetCategories`:
10+
* `TargetIds` can be used to specify one or more specific target IDs:
11+
* `TargetIds(1, 2, 3)` or
12+
* `TargetIds(ComponentIds(1), ComponentIds(2), ComponentIds(3))`
13+
* `TargetCategories` can be used to specify one or more target categories:
14+
* `TargetCategories(ComponentCategory.BATTERY, ComponentCategory.SOLAR)`
1015

1116
## New Features
1217

1318
* `dispatch-cli` supports now the parameter `--type` and `--running` to filter the list of running services by type and status, respectively.
1419
* 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__()`:
1520
* `default_timeout: timedelta` (default: 60 seconds)
1621
* `stream_timeout: timedelta` (default: 5 minutes)
22+
* With the new `TargetCategory` class (providing `.category` and `.type`) we can now specify subtypes of the categories:
23+
* `ComponentCategory.BATTERY` uses `BatteryType` with possible values: `LI_ION`, `NA_ION`
24+
* `ComponentCategory.INVERTER` uses `InverterType` with possible values: `BATTERY`, `SOLAR`, `HYBRID`
25+
* `ComponentCategory.EV_CHARGER` uses `EvChargerType`: with possible values `AC`, `DC`, `HYBRID`
26+
* A few examples on how to use the new `TargetCategory`:
27+
* `TargetCategory(BatteryType.LI_ION)`
28+
* `category` is `ComponentCategory.BATTERY`
29+
* `type` is `BatteryType.LI_ION`
30+
* `TargetCategory(ComponentCategory.BATTERY)`
31+
* `category` is `ComponentCategory.BATTERY`
32+
* `type` is `None`
33+
* `TargetCategories(InverterType.SOLAR)`
34+
* `category` is `ComponentCategory.INVERTER`
35+
* `type` is `InverterType.SOLAR`
36+
1737

1838
## Bug Fixes
1939

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ classifiers = [
3737
requires-python = ">= 3.11, < 4"
3838
dependencies = [
3939
"typing-extensions >= 4.6.1, < 5",
40-
"frequenz-api-dispatch == 1.0.0-rc1",
40+
"frequenz-api-dispatch == 1.0.0-rc2",
4141
"frequenz-client-base >= 0.8.0, < 0.12.0",
4242
"frequenz-client-common >= 0.1.0, < 0.4.0",
4343
"grpcio >= 1.70.0, < 2",
@@ -74,6 +74,7 @@ dev-mkdocs = [
7474
"mike == 2.1.3",
7575
"mkdocs-gen-files == 0.5.0",
7676
"mkdocs-literate-nav == 0.6.2",
77+
"frequenz-api-dispatch == 1.0.0-rc2",
7778
"mkdocs-macros-plugin == 1.3.7",
7879
"mkdocs-material == 9.6.14",
7980
"mkdocstrings[python] == 0.29.1",
@@ -93,7 +94,7 @@ dev-pylint = [
9394
"pylint == 3.3.7",
9495
# For checking the noxfile, docs/ script, and tests
9596
"frequenz-client-dispatch[cli,dev-mkdocs,dev-noxfile,dev-pytest]",
96-
"frequenz-api-dispatch == 1.0.0-rc1",
97+
"frequenz-api-dispatch == 1.0.0-rc2",
9798
]
9899
dev-pytest = [
99100
"pytest == 8.3.5",

src/frequenz/client/dispatch/__main__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ def print_dispatch(dispatch: Dispatch) -> None:
8686
# Format the target
8787
if dispatch.target:
8888
if len(dispatch.target) == 1:
89-
target_str: str = str(dispatch.target[0])
89+
(first_element,) = dispatch.target
90+
target_str: str = str(first_element)
9091
else:
9192
target_str = ", ".join(str(s) for s in dispatch.target)
9293
else:

src/frequenz/client/dispatch/_cli_types.py

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@
55

66
import json
77
from datetime import datetime, timedelta, timezone
8+
from itertools import chain
89
from typing import Any, Literal, cast
910

1011
import asyncclick as click
1112
import parsedatetime # type: ignore
1213
from tzlocal import get_localzone
1314

1415
from frequenz.client.common.microgrid.components import ComponentCategory
16+
from frequenz.client.dispatch.types import (
17+
BatteryType,
18+
EvChargerType,
19+
InverterType,
20+
TargetCategories,
21+
TargetComponents,
22+
TargetIds,
23+
)
1524

1625
# Disable a false positive from pylint
1726
# pylint: disable=inconsistent-return-statements
@@ -140,7 +149,7 @@ class TargetComponentParamType(click.ParamType):
140149

141150
def convert(
142151
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
143-
) -> list[ComponentCategory] | list[int]:
152+
) -> TargetIds | TargetCategories:
144153
"""Convert the input value into a list of ComponentCategory or IDs.
145154
146155
Args:
@@ -149,9 +158,9 @@ def convert(
149158
ctx: The Click context object.
150159
151160
Returns:
152-
A list of component ids or component categories.
161+
A list of targets, either as component IDs or component categories.
153162
"""
154-
if isinstance(value, list): # Already a list
163+
if isinstance(value, TargetComponents):
155164
return value
156165

157166
values = value.split(",")
@@ -162,20 +171,46 @@ def convert(
162171
error: Exception | None = None
163172
# Attempt to parse component ids
164173
try:
165-
return [int(id) for id in values]
174+
return TargetIds(*[int(id) for id in values])
166175
except ValueError as e:
167176
error = e
168177

178+
def enum_from_str(
179+
name: str,
180+
) -> InverterType | BatteryType | EvChargerType | ComponentCategory:
181+
"""Convert a string to an enum member."""
182+
name = name.strip().upper()
183+
if name in ComponentCategory.__members__:
184+
return ComponentCategory[name]
185+
if name in InverterType.__members__:
186+
return InverterType[name]
187+
if name in BatteryType.__members__:
188+
return BatteryType[name]
189+
if name in EvChargerType.__members__:
190+
return EvChargerType[name]
191+
raise KeyError(f"Invalid target specification: {name}")
192+
169193
# Attempt to parse as component categories, trim whitespace
170194
try:
171-
return [ComponentCategory[cat.strip().upper()] for cat in values]
195+
return TargetCategories(*[enum_from_str(cat) for cat in values])
172196
except KeyError as e:
173197
error = e
174198

199+
types_str = ", ".join(
200+
[f"{type.name}" for type in chain(BatteryType, InverterType, EvChargerType)]
201+
)
202+
175203
self.fail(
176204
f'Invalid component category list or ID list: "{value}".\n'
177205
f'Error: "{error}"\n\n'
178-
"Possible categories: BATTERY, GRID, METER, INVERTER, EV_CHARGER, CHP ",
206+
"Valid formats:\n"
207+
"- 1,2,3 # A list of component IDs\n"
208+
"- METER,INVERTER # A list of component categories\n"
209+
"- NA_ION,SOLAR # A list of component category types (category is derived)\n"
210+
"Valid categories:\n"
211+
f"{', '.join([cat.name for cat in ComponentCategory])}\n"
212+
"Valid types:\n"
213+
f"{types_str}\n",
179214
param,
180215
ctx,
181216
)

src/frequenz/client/dispatch/_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ def to_interval(
166166
) -> PBTimeIntervalFilter | None:
167167
return (
168168
PBTimeIntervalFilter(
169-
**{"from": to_timestamp(from_)}, to=to_timestamp(to)
169+
from_time=to_timestamp(from_), to_time=to_timestamp(to)
170170
)
171171
if from_ or to
172172
else None

src/frequenz/client/dispatch/recurrence.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ def from_protobuf(cls, pb_criteria: PBRecurrenceRule.EndCriteria) -> "EndCriteri
8888
match pb_criteria.WhichOneof("count_or_until"):
8989
case "count":
9090
instance.count = pb_criteria.count
91-
case "until":
92-
instance.until = to_datetime(pb_criteria.until)
91+
case "until_time":
92+
instance.until = to_datetime(pb_criteria.until_time)
9393
return instance
9494

9595
def to_protobuf(self) -> PBRecurrenceRule.EndCriteria:
@@ -103,7 +103,7 @@ def to_protobuf(self) -> PBRecurrenceRule.EndCriteria:
103103
if self.count is not None:
104104
pb_criteria.count = self.count
105105
elif self.until is not None:
106-
pb_criteria.until.CopyFrom(to_timestamp(self.until))
106+
pb_criteria.until_time.CopyFrom(to_timestamp(self.until))
107107

108108
return pb_criteria
109109

src/frequenz/client/dispatch/test/_service.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,20 +188,20 @@ def _filter_dispatch(
188188
if target != dispatch.target:
189189
return False
190190
if _filter.HasField("start_time_interval"):
191-
if start_from := _filter.start_time_interval.__dict__["from"]:
191+
if start_from := _filter.start_time_interval.from_time:
192192
if dispatch.start_time < _to_dt(start_from):
193193
return False
194-
if start_to := _filter.start_time_interval.to:
194+
if start_to := _filter.start_time_interval.to_time:
195195
if dispatch.start_time >= _to_dt(start_to):
196196
return False
197197
if _filter.HasField("end_time_interval"):
198-
if end_from := _filter.end_time_interval.__dict__["from"]:
198+
if end_from := _filter.end_time_interval.from_time:
199199
if (
200200
dispatch.duration
201201
and dispatch.start_time + dispatch.duration < _to_dt(end_from)
202202
):
203203
return False
204-
if end_to := _filter.end_time_interval.to:
204+
if end_to := _filter.end_time_interval.to_time:
205205
if (
206206
dispatch.duration
207207
and dispatch.start_time + dispatch.duration >= _to_dt(end_to)

src/frequenz/client/dispatch/test/generator.py

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@
1010

1111
from .._internal_types import rounded_start_time
1212
from ..recurrence import EndCriteria, Frequency, RecurrenceRule, Weekday
13-
from ..types import Dispatch
13+
from ..types import (
14+
BatteryType,
15+
Dispatch,
16+
EvChargerType,
17+
InverterType,
18+
TargetCategories,
19+
TargetCategory,
20+
TargetComponents,
21+
TargetIds,
22+
)
1423

1524

1625
class DispatchGenerator:
@@ -66,6 +75,27 @@ def generate_recurrence_rule(self) -> RecurrenceRule:
6675
],
6776
)
6877

78+
def generate_target_category_and_type(self) -> TargetCategory:
79+
"""Generate a random category and type.
80+
81+
Returns:
82+
a random category and type
83+
"""
84+
category = self._rng.choice(list(ComponentCategory)[1:])
85+
category_type: BatteryType | InverterType | EvChargerType | None = None
86+
87+
match category:
88+
case ComponentCategory.BATTERY:
89+
category_type = self._rng.choice(list(BatteryType)[1:])
90+
case ComponentCategory.INVERTER:
91+
category_type = self._rng.choice(list(InverterType)[1:])
92+
case ComponentCategory.EV_CHARGER:
93+
category_type = self._rng.choice(list(EvChargerType)[1:])
94+
case _:
95+
category_type = None
96+
97+
return TargetCategory(category_type or category)
98+
6999
def generate_dispatch(self) -> Dispatch:
70100
"""Generate a random dispatch instance.
71101
@@ -77,6 +107,20 @@ def generate_dispatch(self) -> Dispatch:
77107
self._rng.randint(0, 1000000), tz=timezone.utc
78108
)
79109

110+
target_choices: list[TargetComponents] = [
111+
TargetIds(
112+
*[self._rng.randint(1, 100) for _ in range(self._rng.randint(1, 10))]
113+
),
114+
TargetCategories(
115+
*[
116+
# Not yet used
117+
# self.generate_target_category_and_type()
118+
self._rng.choice(list(ComponentCategory)[1:])
119+
for _ in range(self._rng.randint(1, 10))
120+
]
121+
),
122+
]
123+
80124
return Dispatch(
81125
id=self._last_id,
82126
create_time=create_time,
@@ -92,18 +136,7 @@ def generate_dispatch(self) -> Dispatch:
92136
timedelta(seconds=self._rng.randint(0, 1000000)),
93137
]
94138
),
95-
target=self._rng.choice( # type: ignore
96-
[
97-
[
98-
self._rng.choice(list(ComponentCategory)[1:])
99-
for _ in range(self._rng.randint(1, 10))
100-
],
101-
[
102-
self._rng.randint(1, 100)
103-
for _ in range(self._rng.randint(1, 10))
104-
],
105-
]
106-
),
139+
target=self._rng.choice(target_choices),
107140
active=self._rng.choice([True, False]),
108141
dry_run=self._rng.choice([True, False]),
109142
payload={

0 commit comments

Comments
 (0)