Skip to content

Allow targeting categories with subtypes #168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,34 @@

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
* `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

* `dispatch-cli` supports now the parameter `--type` and `--running` to filter the list of running services by type and status, respectively.
* 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

Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -74,6 +74,7 @@ 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.14",
"mkdocstrings[python] == 0.29.1",
Expand All @@ -93,7 +94,7 @@ dev-pylint = [
"pylint == 3.3.7",
# 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",
Expand Down
3 changes: 2 additions & 1 deletion src/frequenz/client/dispatch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 41 additions & 6 deletions src/frequenz/client/dispatch/_cli_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@

import json
from datetime import datetime, timedelta, timezone
from itertools import chain
from typing import Any, Literal, cast

import asyncclick as click
import parsedatetime # type: ignore
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
Expand Down Expand Up @@ -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:
Expand All @@ -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(",")
Expand All @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion src/frequenz/client/dispatch/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/frequenz/client/dispatch/recurrence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
8 changes: 4 additions & 4 deletions src/frequenz/client/dispatch/test/_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
59 changes: 46 additions & 13 deletions src/frequenz/client/dispatch/test/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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,
Expand All @@ -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={
Expand Down
Loading
Loading