Skip to content
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
11 changes: 10 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
- Now component and microgrid IDs are wrapped in new classes: `ComponentId` and `MicrogridId` respectively.

These classes provide type safety and prevent accidental errors by:

- Making it impossible to mix up microgrid and component IDs (equality comparisons between different ID types always return false).
- Preventing accidental math operations on IDs.
- Providing clear string representations for debugging (MID42, CID42).
- Ensuring proper hash behavior in collections.

To migrate you just need to wrap your `int` IDs with the appropriate class: `0` -> `ComponentId(0)` / `MicrogridId(0)`.

## New Features

Expand Down
5 changes: 4 additions & 1 deletion src/frequenz/client/microgrid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@
UnknownError,
UnrecognizedGrpcStatus,
)
from ._id import ComponentId, MicrogridId
from ._metadata import Location, Metadata

__all__ = [
"MicrogridApiClient",
"ApiClientError",
"BatteryComponentState",
"BatteryData",
Expand All @@ -76,6 +76,7 @@
"Component",
"ComponentCategory",
"ComponentData",
"ComponentId",
"ComponentMetadata",
"ComponentMetricId",
"ComponentType",
Expand All @@ -100,6 +101,8 @@
"Location",
"Metadata",
"MeterData",
"MicrogridApiClient",
"MicrogridId",
"OperationAborted",
"OperationCancelled",
"OperationNotImplemented",
Expand Down
59 changes: 34 additions & 25 deletions src/frequenz/client/microgrid/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from ._connection import Connection
from ._constants import RECEIVER_MAX_SIZE
from ._exception import ApiClientError, ClientNotConnected
from ._id import ComponentId, MicrogridId
from ._metadata import Location, Metadata

DEFAULT_GRPC_CALL_TIMEOUT = 60.0
Expand Down Expand Up @@ -91,7 +92,9 @@ def __init__(
connect=connect,
channel_defaults=channel_defaults,
)
self._broadcasters: dict[int, streaming.GrpcStreamBroadcaster[Any, Any]] = {}
self._broadcasters: dict[
ComponentId, streaming.GrpcStreamBroadcaster[Any, Any]
] = {}
self._retry_strategy = retry_strategy

@property
Expand Down Expand Up @@ -134,7 +137,7 @@ async def components( # noqa: DOC502 (raises ApiClientError indirectly)
)
result: Iterable[Component] = map(
lambda c: Component(
c.id,
ComponentId(c.id),
component_category_from_protobuf(c.category),
component_type_from_protobuf(c.category, c.inverter),
component_metadata_from_protobuf(c.category, c.grid),
Expand Down Expand Up @@ -176,12 +179,14 @@ async def metadata(self) -> Metadata:
longitude=microgrid_metadata.location.longitude,
)

return Metadata(microgrid_id=microgrid_metadata.microgrid_id, location=location)
return Metadata(
microgrid_id=MicrogridId(microgrid_metadata.microgrid_id), location=location
)

async def connections( # noqa: DOC502 (raises ApiClientError indirectly)
self,
starts: Set[int] = frozenset(),
ends: Set[int] = frozenset(),
starts: Set[ComponentId] = frozenset(),
ends: Set[ComponentId] = frozenset(),
) -> Iterable[Connection]:
"""Fetch the connections between components in the microgrid.

Expand All @@ -199,7 +204,13 @@ async def connections( # noqa: DOC502 (raises ApiClientError indirectly)
most likely a subclass of
[GrpcError][frequenz.client.microgrid.GrpcError].
"""
connection_filter = microgrid_pb2.ConnectionFilter(starts=starts, ends=ends)
# Convert ComponentId to raw int for the API call
start_ids = {int(start) for start in starts}
end_ids = {int(end) for end in ends}

connection_filter = microgrid_pb2.ConnectionFilter(
starts=start_ids, ends=end_ids
)
valid_components, all_connections = await asyncio.gather(
self.components(),
client.call_stub_method(
Expand All @@ -214,7 +225,7 @@ async def connections( # noqa: DOC502 (raises ApiClientError indirectly)

# Filter out the components filtered in `components` method.
# id=0 is an exception indicating grid component.
valid_ids = {c.component_id for c in valid_components}
valid_ids = {int(c.component_id) for c in valid_components}
valid_ids.add(0)

connections = filter(
Expand All @@ -223,15 +234,15 @@ async def connections( # noqa: DOC502 (raises ApiClientError indirectly)
)

result: Iterable[Connection] = map(
lambda c: Connection(c.start, c.end), connections
lambda c: Connection(ComponentId(c.start), ComponentId(c.end)), connections
)

return result

async def _new_component_data_receiver(
self,
*,
component_id: int,
component_id: ComponentId,
expected_category: ComponentCategory,
transform: Callable[[microgrid_pb2.ComponentData], _ComponentDataT],
maxsize: int,
Expand Down Expand Up @@ -262,7 +273,7 @@ async def _new_component_data_receiver(
f"raw-component-data-{component_id}",
lambda: aiter(
self.stub.StreamComponentData(
microgrid_pb2.ComponentIdParam(id=component_id)
microgrid_pb2.ComponentIdParam(id=int(component_id))
)
),
transform,
Expand All @@ -276,7 +287,7 @@ async def _new_component_data_receiver(

async def _expect_category(
self,
component_id: int,
component_id: ComponentId,
expected_category: ComponentCategory,
) -> None:
"""Check if the given component_id is of the expected type.
Expand All @@ -296,19 +307,17 @@ async def _expect_category(
if comp.component_id == component_id
)
except StopIteration as exc:
raise ValueError(
f"Unable to find component with id {component_id}"
) from exc
raise ValueError(f"Unable to find {component_id}") from exc

if comp.category != expected_category:
raise ValueError(
f"Component id {component_id} is a {comp.category.name.lower()}"
f"{component_id} is a {comp.category.name.lower()}"
f", not a {expected_category.name.lower()}."
)

async def meter_data( # noqa: DOC502 (ValueError is raised indirectly by _expect_category)
self,
component_id: int,
component_id: ComponentId,
maxsize: int = RECEIVER_MAX_SIZE,
) -> Receiver[MeterData]:
"""Return a channel receiver that provides a `MeterData` stream.
Expand All @@ -332,7 +341,7 @@ async def meter_data( # noqa: DOC502 (ValueError is raised indirectly by _expec

async def battery_data( # noqa: DOC502 (ValueError is raised indirectly by _expect_category)
self,
component_id: int,
component_id: ComponentId,
maxsize: int = RECEIVER_MAX_SIZE,
) -> Receiver[BatteryData]:
"""Return a channel receiver that provides a `BatteryData` stream.
Expand All @@ -356,7 +365,7 @@ async def battery_data( # noqa: DOC502 (ValueError is raised indirectly by _exp

async def inverter_data( # noqa: DOC502 (ValueError is raised indirectly by _expect_category)
self,
component_id: int,
component_id: ComponentId,
maxsize: int = RECEIVER_MAX_SIZE,
) -> Receiver[InverterData]:
"""Return a channel receiver that provides an `InverterData` stream.
Expand All @@ -380,7 +389,7 @@ async def inverter_data( # noqa: DOC502 (ValueError is raised indirectly by _ex

async def ev_charger_data( # noqa: DOC502 (ValueError is raised indirectly by _expect_category)
self,
component_id: int,
component_id: ComponentId,
maxsize: int = RECEIVER_MAX_SIZE,
) -> Receiver[EVChargerData]:
"""Return a channel receiver that provides an `EvChargeData` stream.
Expand All @@ -403,7 +412,7 @@ async def ev_charger_data( # noqa: DOC502 (ValueError is raised indirectly by _
)

async def set_power( # noqa: DOC502 (raises ApiClientError indirectly)
self, component_id: int, power_w: float
self, component_id: ComponentId, power_w: float
) -> None:
"""Send request to the Microgrid to set power for component.

Expand All @@ -425,15 +434,15 @@ async def set_power( # noqa: DOC502 (raises ApiClientError indirectly)
self,
lambda: self.stub.SetPowerActive(
microgrid_pb2.SetPowerActiveParam(
component_id=component_id, power=power_w
component_id=int(component_id), power=power_w
),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
),
method_name="SetPowerActive",
)

async def set_reactive_power( # noqa: DOC502 (raises ApiClientError indirectly)
self, component_id: int, reactive_power_var: float
self, component_id: ComponentId, reactive_power_var: float
) -> None:
"""Send request to the Microgrid to set reactive power for component.

Expand All @@ -453,7 +462,7 @@ async def set_reactive_power( # noqa: DOC502 (raises ApiClientError indirectly)
self,
lambda: self.stub.SetPowerReactive(
microgrid_pb2.SetPowerReactiveParam(
component_id=component_id, power=reactive_power_var
component_id=int(component_id), power=reactive_power_var
),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
),
Expand All @@ -462,7 +471,7 @@ async def set_reactive_power( # noqa: DOC502 (raises ApiClientError indirectly)

async def set_bounds( # noqa: DOC503 (raises ApiClientError indirectly)
self,
component_id: int,
component_id: ComponentId,
lower: float,
upper: float,
) -> None:
Expand Down Expand Up @@ -492,7 +501,7 @@ async def set_bounds( # noqa: DOC503 (raises ApiClientError indirectly)
self,
lambda: self.stub.AddInclusionBounds(
microgrid_pb2.SetBoundsParam(
component_id=component_id,
component_id=int(component_id),
target_metric=target_metric,
bounds=metrics_pb2.Bounds(lower=lower, upper=upper),
),
Expand Down
9 changes: 6 additions & 3 deletions src/frequenz/client/microgrid/_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from frequenz.api.common import components_pb2
from frequenz.api.microgrid import grid_pb2, inverter_pb2

from ._id import ComponentId


class ComponentType(Enum):
"""A base class from which individual component types are derived."""
Expand Down Expand Up @@ -160,7 +162,7 @@ def component_metadata_from_protobuf(
class Component:
"""Metadata for a single microgrid component."""

component_id: int
component_id: ComponentId
"""The ID of this component."""

category: ComponentCategory
Expand All @@ -180,8 +182,9 @@ def is_valid(self) -> bool:
== 0` and `type` is `GRID`, `False` otherwise
"""
return (
self.component_id > 0 and any(t == self.category for t in ComponentCategory)
) or (self.component_id == 0 and self.category == ComponentCategory.GRID)
int(self.component_id) > 0
and any(t == self.category for t in ComponentCategory)
) or (int(self.component_id) == 0 and self.category == ComponentCategory.GRID)

def __hash__(self) -> int:
"""Compute a hash of this instance, obtained by hashing the `component_id` field.
Expand Down
11 changes: 6 additions & 5 deletions src/frequenz/client/microgrid/_component_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@
EVChargerComponentState,
InverterComponentState,
)
from ._id import ComponentId


@dataclass(frozen=True)
class ComponentData(ABC):
"""A private base class for strongly typed component data classes."""

component_id: int
component_id: ComponentId
"""The ID identifying this component in the microgrid."""

timestamp: datetime
Expand Down Expand Up @@ -129,7 +130,7 @@ def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self:
Instance of MeterData created from the protobuf message.
"""
meter_data = cls(
component_id=raw.id,
component_id=ComponentId(raw.id),
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
active_power=raw.meter.data.ac.power_active.value,
active_power_per_phase=(
Expand Down Expand Up @@ -247,7 +248,7 @@ def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self:
"""
raw_power = raw.battery.data.dc.power
battery_data = cls(
component_id=raw.id,
component_id=ComponentId(raw.id),
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
soc=raw.battery.data.soc.avg,
soc_lower_bound=raw.battery.data.soc.system_inclusion_bounds.lower,
Expand Down Expand Up @@ -385,7 +386,7 @@ def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self:
"""
raw_power = raw.inverter.data.ac.power_active
inverter_data = cls(
component_id=raw.id,
component_id=ComponentId(raw.id),
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
active_power=raw.inverter.data.ac.power_active.value,
active_power_per_phase=(
Expand Down Expand Up @@ -541,7 +542,7 @@ def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self:
"""
raw_power = raw.ev_charger.data.ac.power_active
ev_charger_data = cls(
component_id=raw.id,
component_id=ComponentId(raw.id),
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
active_power=raw_power.value,
active_power_per_phase=(
Expand Down
8 changes: 5 additions & 3 deletions src/frequenz/client/microgrid/_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@

from dataclasses import dataclass

from ._id import ComponentId


@dataclass(frozen=True)
class Connection:
"""Metadata for a connection between microgrid components."""

start: int
start: ComponentId
"""The component ID that represents the start component of the connection."""

end: int
end: ComponentId
"""The component ID that represents the end component of the connection."""

def is_valid(self) -> bool:
Expand All @@ -24,4 +26,4 @@ def is_valid(self) -> bool:
`True` if `start >= 0`, `end > 0`, and `start != end`, `False`
otherwise.
"""
return self.start >= 0 and self.end > 0 and self.start != self.end
return int(self.start) >= 0 and int(self.end) > 0 and self.start != self.end
Loading