Skip to content

Commit 8bd31f1

Browse files
authored
Add ID wrapper classes (#122)
Introduce `MicrogridId` and `ComponentId` classes to replace plain integer IDs. 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.
2 parents 18287d0 + b6dbc6a commit 8bd31f1

File tree

14 files changed

+422
-139
lines changed

14 files changed

+422
-139
lines changed

RELEASE_NOTES.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@
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+
- Now component and microgrid IDs are wrapped in new classes: `ComponentId` and `MicrogridId` respectively.
10+
11+
These classes provide type safety and prevent accidental errors by:
12+
13+
- Making it impossible to mix up microgrid and component IDs (equality comparisons between different ID types always return false).
14+
- Preventing accidental math operations on IDs.
15+
- Providing clear string representations for debugging (MID42, CID42).
16+
- Ensuring proper hash behavior in collections.
17+
18+
To migrate you just need to wrap your `int` IDs with the appropriate class: `0` -> `ComponentId(0)` / `MicrogridId(0)`.
1019

1120
## New Features
1221

src/frequenz/client/microgrid/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@
6262
UnknownError,
6363
UnrecognizedGrpcStatus,
6464
)
65+
from ._id import ComponentId, MicrogridId
6566
from ._metadata import Location, Metadata
6667

6768
__all__ = [
68-
"MicrogridApiClient",
6969
"ApiClientError",
7070
"BatteryComponentState",
7171
"BatteryData",
@@ -76,6 +76,7 @@
7676
"Component",
7777
"ComponentCategory",
7878
"ComponentData",
79+
"ComponentId",
7980
"ComponentMetadata",
8081
"ComponentMetricId",
8182
"ComponentType",
@@ -100,6 +101,8 @@
100101
"Location",
101102
"Metadata",
102103
"MeterData",
104+
"MicrogridApiClient",
105+
"MicrogridId",
103106
"OperationAborted",
104107
"OperationCancelled",
105108
"OperationNotImplemented",

src/frequenz/client/microgrid/_client.py

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from ._connection import Connection
3535
from ._constants import RECEIVER_MAX_SIZE
3636
from ._exception import ApiClientError, ClientNotConnected
37+
from ._id import ComponentId, MicrogridId
3738
from ._metadata import Location, Metadata
3839

3940
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
@@ -91,7 +92,9 @@ def __init__(
9192
connect=connect,
9293
channel_defaults=channel_defaults,
9394
)
94-
self._broadcasters: dict[int, streaming.GrpcStreamBroadcaster[Any, Any]] = {}
95+
self._broadcasters: dict[
96+
ComponentId, streaming.GrpcStreamBroadcaster[Any, Any]
97+
] = {}
9598
self._retry_strategy = retry_strategy
9699

97100
@property
@@ -134,7 +137,7 @@ async def components( # noqa: DOC502 (raises ApiClientError indirectly)
134137
)
135138
result: Iterable[Component] = map(
136139
lambda c: Component(
137-
c.id,
140+
ComponentId(c.id),
138141
component_category_from_protobuf(c.category),
139142
component_type_from_protobuf(c.category, c.inverter),
140143
component_metadata_from_protobuf(c.category, c.grid),
@@ -176,12 +179,14 @@ async def metadata(self) -> Metadata:
176179
longitude=microgrid_metadata.location.longitude,
177180
)
178181

179-
return Metadata(microgrid_id=microgrid_metadata.microgrid_id, location=location)
182+
return Metadata(
183+
microgrid_id=MicrogridId(microgrid_metadata.microgrid_id), location=location
184+
)
180185

181186
async def connections( # noqa: DOC502 (raises ApiClientError indirectly)
182187
self,
183-
starts: Set[int] = frozenset(),
184-
ends: Set[int] = frozenset(),
188+
starts: Set[ComponentId] = frozenset(),
189+
ends: Set[ComponentId] = frozenset(),
185190
) -> Iterable[Connection]:
186191
"""Fetch the connections between components in the microgrid.
187192
@@ -199,7 +204,13 @@ async def connections( # noqa: DOC502 (raises ApiClientError indirectly)
199204
most likely a subclass of
200205
[GrpcError][frequenz.client.microgrid.GrpcError].
201206
"""
202-
connection_filter = microgrid_pb2.ConnectionFilter(starts=starts, ends=ends)
207+
# Convert ComponentId to raw int for the API call
208+
start_ids = {int(start) for start in starts}
209+
end_ids = {int(end) for end in ends}
210+
211+
connection_filter = microgrid_pb2.ConnectionFilter(
212+
starts=start_ids, ends=end_ids
213+
)
203214
valid_components, all_connections = await asyncio.gather(
204215
self.components(),
205216
client.call_stub_method(
@@ -214,7 +225,7 @@ async def connections( # noqa: DOC502 (raises ApiClientError indirectly)
214225

215226
# Filter out the components filtered in `components` method.
216227
# id=0 is an exception indicating grid component.
217-
valid_ids = {c.component_id for c in valid_components}
228+
valid_ids = {int(c.component_id) for c in valid_components}
218229
valid_ids.add(0)
219230

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

225236
result: Iterable[Connection] = map(
226-
lambda c: Connection(c.start, c.end), connections
237+
lambda c: Connection(ComponentId(c.start), ComponentId(c.end)), connections
227238
)
228239

229240
return result
230241

231242
async def _new_component_data_receiver(
232243
self,
233244
*,
234-
component_id: int,
245+
component_id: ComponentId,
235246
expected_category: ComponentCategory,
236247
transform: Callable[[microgrid_pb2.ComponentData], _ComponentDataT],
237248
maxsize: int,
@@ -262,7 +273,7 @@ async def _new_component_data_receiver(
262273
f"raw-component-data-{component_id}",
263274
lambda: aiter(
264275
self.stub.StreamComponentData(
265-
microgrid_pb2.ComponentIdParam(id=component_id)
276+
microgrid_pb2.ComponentIdParam(id=int(component_id))
266277
)
267278
),
268279
transform,
@@ -276,7 +287,7 @@ async def _new_component_data_receiver(
276287

277288
async def _expect_category(
278289
self,
279-
component_id: int,
290+
component_id: ComponentId,
280291
expected_category: ComponentCategory,
281292
) -> None:
282293
"""Check if the given component_id is of the expected type.
@@ -296,19 +307,17 @@ async def _expect_category(
296307
if comp.component_id == component_id
297308
)
298309
except StopIteration as exc:
299-
raise ValueError(
300-
f"Unable to find component with id {component_id}"
301-
) from exc
310+
raise ValueError(f"Unable to find {component_id}") from exc
302311

303312
if comp.category != expected_category:
304313
raise ValueError(
305-
f"Component id {component_id} is a {comp.category.name.lower()}"
314+
f"{component_id} is a {comp.category.name.lower()}"
306315
f", not a {expected_category.name.lower()}."
307316
)
308317

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

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

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

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

405414
async def set_power( # noqa: DOC502 (raises ApiClientError indirectly)
406-
self, component_id: int, power_w: float
415+
self, component_id: ComponentId, power_w: float
407416
) -> None:
408417
"""Send request to the Microgrid to set power for component.
409418
@@ -425,15 +434,15 @@ async def set_power( # noqa: DOC502 (raises ApiClientError indirectly)
425434
self,
426435
lambda: self.stub.SetPowerActive(
427436
microgrid_pb2.SetPowerActiveParam(
428-
component_id=component_id, power=power_w
437+
component_id=int(component_id), power=power_w
429438
),
430439
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
431440
),
432441
method_name="SetPowerActive",
433442
)
434443

435444
async def set_reactive_power( # noqa: DOC502 (raises ApiClientError indirectly)
436-
self, component_id: int, reactive_power_var: float
445+
self, component_id: ComponentId, reactive_power_var: float
437446
) -> None:
438447
"""Send request to the Microgrid to set reactive power for component.
439448
@@ -453,7 +462,7 @@ async def set_reactive_power( # noqa: DOC502 (raises ApiClientError indirectly)
453462
self,
454463
lambda: self.stub.SetPowerReactive(
455464
microgrid_pb2.SetPowerReactiveParam(
456-
component_id=component_id, power=reactive_power_var
465+
component_id=int(component_id), power=reactive_power_var
457466
),
458467
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
459468
),
@@ -462,7 +471,7 @@ async def set_reactive_power( # noqa: DOC502 (raises ApiClientError indirectly)
462471

463472
async def set_bounds( # noqa: DOC503 (raises ApiClientError indirectly)
464473
self,
465-
component_id: int,
474+
component_id: ComponentId,
466475
lower: float,
467476
upper: float,
468477
) -> None:
@@ -492,7 +501,7 @@ async def set_bounds( # noqa: DOC503 (raises ApiClientError indirectly)
492501
self,
493502
lambda: self.stub.AddInclusionBounds(
494503
microgrid_pb2.SetBoundsParam(
495-
component_id=component_id,
504+
component_id=int(component_id),
496505
target_metric=target_metric,
497506
bounds=metrics_pb2.Bounds(lower=lower, upper=upper),
498507
),

src/frequenz/client/microgrid/_component.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from frequenz.api.common import components_pb2
1010
from frequenz.api.microgrid import grid_pb2, inverter_pb2
1111

12+
from ._id import ComponentId
13+
1214

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

163-
component_id: int
165+
component_id: ComponentId
164166
"""The ID of this component."""
165167

166168
category: ComponentCategory
@@ -180,8 +182,9 @@ def is_valid(self) -> bool:
180182
== 0` and `type` is `GRID`, `False` otherwise
181183
"""
182184
return (
183-
self.component_id > 0 and any(t == self.category for t in ComponentCategory)
184-
) or (self.component_id == 0 and self.category == ComponentCategory.GRID)
185+
int(self.component_id) > 0
186+
and any(t == self.category for t in ComponentCategory)
187+
) or (int(self.component_id) == 0 and self.category == ComponentCategory.GRID)
185188

186189
def __hash__(self) -> int:
187190
"""Compute a hash of this instance, obtained by hashing the `component_id` field.

src/frequenz/client/microgrid/_component_data.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@
1818
EVChargerComponentState,
1919
InverterComponentState,
2020
)
21+
from ._id import ComponentId
2122

2223

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

27-
component_id: int
28+
component_id: ComponentId
2829
"""The ID identifying this component in the microgrid."""
2930

3031
timestamp: datetime
@@ -129,7 +130,7 @@ def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self:
129130
Instance of MeterData created from the protobuf message.
130131
"""
131132
meter_data = cls(
132-
component_id=raw.id,
133+
component_id=ComponentId(raw.id),
133134
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
134135
active_power=raw.meter.data.ac.power_active.value,
135136
active_power_per_phase=(
@@ -247,7 +248,7 @@ def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self:
247248
"""
248249
raw_power = raw.battery.data.dc.power
249250
battery_data = cls(
250-
component_id=raw.id,
251+
component_id=ComponentId(raw.id),
251252
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
252253
soc=raw.battery.data.soc.avg,
253254
soc_lower_bound=raw.battery.data.soc.system_inclusion_bounds.lower,
@@ -385,7 +386,7 @@ def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self:
385386
"""
386387
raw_power = raw.inverter.data.ac.power_active
387388
inverter_data = cls(
388-
component_id=raw.id,
389+
component_id=ComponentId(raw.id),
389390
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
390391
active_power=raw.inverter.data.ac.power_active.value,
391392
active_power_per_phase=(
@@ -541,7 +542,7 @@ def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self:
541542
"""
542543
raw_power = raw.ev_charger.data.ac.power_active
543544
ev_charger_data = cls(
544-
component_id=raw.id,
545+
component_id=ComponentId(raw.id),
545546
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
546547
active_power=raw_power.value,
547548
active_power_per_phase=(

src/frequenz/client/microgrid/_connection.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66

77
from dataclasses import dataclass
88

9+
from ._id import ComponentId
10+
911

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

14-
start: int
16+
start: ComponentId
1517
"""The component ID that represents the start component of the connection."""
1618

17-
end: int
19+
end: ComponentId
1820
"""The component ID that represents the end component of the connection."""
1921

2022
def is_valid(self) -> bool:
@@ -24,4 +26,4 @@ def is_valid(self) -> bool:
2426
`True` if `start >= 0`, `end > 0`, and `start != end`, `False`
2527
otherwise.
2628
"""
27-
return self.start >= 0 and self.end > 0 and self.start != self.end
29+
return int(self.start) >= 0 and int(self.end) > 0 and self.start != self.end

0 commit comments

Comments
 (0)