Skip to content
6 changes: 4 additions & 2 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

## Summary

<!-- Here goes a general summary of what this release is about -->
This release is a major breaking change, as we jump to the API specification version 0.17.x, which introduces big and fundamental breaking changes. This also starts using the `v1` namespace in `frequenz-api-common`, which also introduces major breaking changes. It would be very hard to detail all the API changes here, please refer to the [Microgrid API releases](https://github.com/frequenz-floss/frequenz-api-microgrid/releases) and [Common API releases](https://github.com/frequenz-floss/frequenz-api-common/releases).

## Upgrading

<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
- `MicrogridApiClient`:

* The client now follows the v0.17 API names, so most methods changed names and signatures.

## New Features

Expand Down
72 changes: 72 additions & 0 deletions src/frequenz/client/microgrid/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import Any, assert_never

from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2
from frequenz.api.common.v1.microgrid.components import components_pb2
from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc
from frequenz.client.base import channel, client, conversion, retry, streaming
from frequenz.client.common.microgrid.components import ComponentId
Expand All @@ -24,7 +25,10 @@
from ._exception import ClientNotConnected
from ._microgrid_info import MicrogridInfo
from ._microgrid_info_proto import microgrid_info_from_proto
from .component._category import ComponentCategory
from .component._component import Component
from .component._component_proto import component_from_proto
from .component._types import ComponentTypes
from .metrics._bounds import Bounds
from .metrics._metric import Metric

Expand Down Expand Up @@ -162,6 +166,61 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly)

return microgrid_info_from_proto(microgrid.microgrid)

async def list_components( # noqa: DOC502 (raises ApiClientError indirectly)
self,
*,
components: Iterable[ComponentId | Component] = (),
categories: Iterable[ComponentCategory | int] = (),
) -> Iterable[ComponentTypes]:
"""Fetch all the components present in the local microgrid.

Electrical components are a part of a microgrid's electrical infrastructure
are can be connected to each other to form an electrical circuit, which can
then be represented as a graph.

If provided, the filters for component and categories have an `AND`
relationship with one another, meaning that they are applied serially,
but the elements within a single filter list have an `OR` relationship with
each other.

Example:
If `ids = {1, 2, 3}`, and `categories = {ComponentCategory.INVERTER,
ComponentCategory.BATTERY}`, then the results will consist of elements that
have:

* The IDs 1, `OR` 2, `OR` 3; `AND`
* Are of the categories `ComponentCategory.INVERTER` `OR`
`ComponentCategory.BATTERY`.

If a filter list is empty, then that filter is not applied.

Args:
components: The components to fetch. See the method description for details.
categories: The categories of the components to fetch. See the method
description for details.

Returns:
Iterator whose elements are all the components in the local microgrid.

Raises:
ApiClientError: If the are any errors communicating with the Microgrid API,
most likely a subclass of
[GrpcError][frequenz.client.microgrid.GrpcError].
"""
component_list = await client.call_stub_method(
self,
lambda: self.stub.ListComponents(
microgrid_pb2.ListComponentsRequest(
component_ids=map(_get_component_id, components),
categories=map(_get_category_value, categories),
),
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
),
method_name="ListComponents",
)

return map(component_from_proto, component_list.components)

async def set_component_power_active( # noqa: DOC502 (raises ApiClientError indirectly)
self,
component: ComponentId | Component,
Expand Down Expand Up @@ -456,6 +515,19 @@ def _get_metric_value(metric: Metric | int) -> metric_sample_pb2.Metric.ValueTyp
assert_never(unexpected)


def _get_category_value(
category: ComponentCategory | int,
) -> components_pb2.ComponentCategory.ValueType:
"""Get the category value from a component or component category."""
match category:
case ComponentCategory():
return components_pb2.ComponentCategory.ValueType(category.value)
case int():
return components_pb2.ComponentCategory.ValueType(category)
case unexpected:
assert_never(unexpected)


def _delta_to_seconds(delta: timedelta | None) -> int | None:
"""Convert a `timedelta` to seconds (or `None` if `None`)."""
return round(delta.total_seconds()) if delta is not None else None
Expand Down
4 changes: 3 additions & 1 deletion src/frequenz/client/microgrid/_lifetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ class Lifetime:
def __post_init__(self) -> None:
"""Validate this lifetime."""
if self.start is not None and self.end is not None and self.start > self.end:
raise ValueError("Start must be before or equal to end.")
raise ValueError(
f"Start ({self.start}) must be before or equal to end ({self.end})"
)

def is_operational_at(self, timestamp: datetime) -> bool:
"""Check whether this lifetime is active at a specific timestamp."""
Expand Down
92 changes: 92 additions & 0 deletions src/frequenz/client/microgrid/component/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,104 @@

"""All classes and functions related to microgrid components."""

from ._battery import (
Battery,
BatteryType,
BatteryTypes,
LiIonBattery,
NaIonBattery,
UnrecognizedBattery,
UnspecifiedBattery,
)
from ._category import ComponentCategory
from ._chp import Chp
from ._component import Component
from ._converter import Converter
from ._crypto_miner import CryptoMiner
from ._electrolyzer import Electrolyzer
from ._ev_charger import (
AcEvCharger,
DcEvCharger,
EvCharger,
EvChargerType,
EvChargerTypes,
HybridEvCharger,
UnrecognizedEvCharger,
UnspecifiedEvCharger,
)
from ._fuse import Fuse
from ._grid_connection_point import GridConnectionPoint
from ._hvac import Hvac
from ._inverter import (
BatteryInverter,
HybridInverter,
Inverter,
InverterType,
SolarInverter,
UnrecognizedInverter,
UnspecifiedInverter,
)
from ._meter import Meter
from ._precharger import Precharger
from ._problematic import (
MismatchedCategoryComponent,
ProblematicComponent,
UnrecognizedComponent,
UnspecifiedComponent,
)
from ._relay import Relay
from ._status import ComponentStatus
from ._types import (
ComponentTypes,
ProblematicComponentTypes,
UnrecognizedComponentTypes,
UnspecifiedComponentTypes,
)
from ._voltage_transformer import VoltageTransformer

__all__ = [
"AcEvCharger",
"Battery",
"BatteryInverter",
"BatteryType",
"BatteryTypes",
"Chp",
"Component",
"ComponentCategory",
"ComponentStatus",
"ComponentTypes",
"Converter",
"CryptoMiner",
"DcEvCharger",
"Electrolyzer",
"EvCharger",
"EvChargerType",
"EvChargerTypes",
"Fuse",
"GridConnectionPoint",
"Hvac",
"HybridEvCharger",
"HybridInverter",
"Inverter",
"InverterType",
"LiIonBattery",
"Meter",
"MismatchedCategoryComponent",
"NaIonBattery",
"Precharger",
"ProblematicComponent",
"ProblematicComponentTypes",
"Relay",
"SolarInverter",
"UnrecognizedBattery",
"UnrecognizedComponent",
"UnrecognizedComponentTypes",
"UnrecognizedEvCharger",
"UnrecognizedInverter",
"UnspecifiedBattery",
"UnspecifiedComponent",
"UnspecifiedComponentTypes",
"UnspecifiedEvCharger",
"UnspecifiedInverter",
"VoltageTransformer",
]
129 changes: 129 additions & 0 deletions src/frequenz/client/microgrid/component/_battery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# License: MIT
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH

"""Battery component."""

import dataclasses
import enum
from typing import Any, Literal, Self, TypeAlias

from frequenz.api.common.v1.microgrid.components import battery_pb2

from ._category import ComponentCategory
from ._component import Component


@enum.unique
class BatteryType(enum.Enum):
"""The known types of batteries."""

UNSPECIFIED = battery_pb2.BATTERY_TYPE_UNSPECIFIED
"""The battery type is unspecified."""

LI_ION = battery_pb2.BATTERY_TYPE_LI_ION
"""Lithium-ion (Li-ion) battery."""

NA_ION = battery_pb2.BATTERY_TYPE_NA_ION
"""Sodium-ion (Na-ion) battery."""


@dataclasses.dataclass(frozen=True, kw_only=True)
class Battery(Component):
"""An abstract battery component."""

category: Literal[ComponentCategory.BATTERY] = ComponentCategory.BATTERY
"""The category of this component.

Note:
This should not be used normally, you should test if a component
[`isinstance`][] of a concrete component class instead.

It is only provided for using with a newer version of the API where the client
doesn't know about a new category yet (i.e. for use with
[`UnrecognizedComponent`][frequenz.client.microgrid.component.UnrecognizedComponent])
and in case some low level code needs to know the category of a component.
"""

type: BatteryType | int
"""The type of this battery.

Note:
This should not be used normally, you should test if a battery
[`isinstance`][] of a concrete battery class instead.

It is only provided for using with a newer version of the API where the client
doesn't know about the new battery type yet (i.e. for use with
[`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]).
"""

# pylint: disable-next=unused-argument
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
"""Prevent instantiation of this class."""
if cls is Battery:
raise TypeError(f"Cannot instantiate {cls.__name__} directly")
return super().__new__(cls)


@dataclasses.dataclass(frozen=True, kw_only=True)
class UnspecifiedBattery(Battery):
"""A battery of a unspecified type."""

type: Literal[BatteryType.UNSPECIFIED] = BatteryType.UNSPECIFIED
"""The type of this battery.

Note:
This should not be used normally, you should test if a battery
[`isinstance`][] of a concrete battery class instead.

It is only provided for using with a newer version of the API where the client
doesn't know about the new battery type yet (i.e. for use with
[`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]).
"""


@dataclasses.dataclass(frozen=True, kw_only=True)
class LiIonBattery(Battery):
"""A Li-ion battery."""

type: Literal[BatteryType.LI_ION] = BatteryType.LI_ION
"""The type of this battery.

Note:
This should not be used normally, you should test if a battery
[`isinstance`][] of a concrete battery class instead.

It is only provided for using with a newer version of the API where the client
doesn't know about the new battery type yet (i.e. for use with
[`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]).
"""


@dataclasses.dataclass(frozen=True, kw_only=True)
class NaIonBattery(Battery):
"""A Na-ion battery."""

type: Literal[BatteryType.NA_ION] = BatteryType.NA_ION
"""The type of this battery.

Note:
This should not be used normally, you should test if a battery
[`isinstance`][] of a concrete battery class instead.

It is only provided for using with a newer version of the API where the client
doesn't know about the new battery type yet (i.e. for use with
[`UnrecognizedBattery`][frequenz.client.microgrid.component.UnrecognizedBattery]).
"""


@dataclasses.dataclass(frozen=True, kw_only=True)
class UnrecognizedBattery(Battery):
"""A battery of an unrecognized type."""

type: int
"""The unrecognized type of this battery."""


BatteryTypes: TypeAlias = (
LiIonBattery | NaIonBattery | UnrecognizedBattery | UnspecifiedBattery
)
"""All possible battery types."""
18 changes: 18 additions & 0 deletions src/frequenz/client/microgrid/component/_chp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# License: MIT
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH

"""CHP component."""

import dataclasses
from typing import Literal

from ._category import ComponentCategory
from ._component import Component


@dataclasses.dataclass(frozen=True, kw_only=True)
class Chp(Component):
"""A combined heat and power (CHP) component."""

category: Literal[ComponentCategory.CHP] = ComponentCategory.CHP
"""The category of this component."""
Loading
Loading