Skip to content
Open
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
4 changes: 3 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

* Added a new `frequenz.client.common.types.Lifetime` type together with the `frequenz.client.common.types.proto.v1alpha8.lifetime_from_proto` conversion function.
* Added a new `frequenz.client.common.types.Location` type together with the `frequenz.client.common.types.proto.v1alpha8.location_from_proto` conversion function.
* Added a new `frequenz.client.common.microgrid.Microgrid` type and `MicrogridStatus` enum together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto`, `microgrid_status_from_proto`, and `microgrid_status_to_proto` conversion functions.
* Added a new `frequenz.client.common.microgrid.Microgrid` type, together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto` conversion function.
* Added a new `frequenz.client.common.ClientCommonError` base exception and `UnspecifiedValueError` at the package root.
* Added a new `frequenz.client.common.microgrid.electrical_components` package, featuring a `ElectricalComponent` class hierarchy and its families (battery, inverter, EV charger, etc.), and `ElectricalComponentConnection`, including `v1alpha8` proto conversion functions.

## Bug Fixes

Expand Down
7 changes: 7 additions & 0 deletions src/frequenz/client/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,10 @@
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH

"""Common code and utilities for Frequenz API clients."""

from ._exception import ClientCommonError, UnspecifiedValueError

__all__ = [
"ClientCommonError",
"UnspecifiedValueError",
]
15 changes: 15 additions & 0 deletions src/frequenz/client/common/_exception.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Common exception types for Frequenz API clients."""


class ClientCommonError(Exception):
"""Base class for all errors raised by frequenz-client-common."""


class UnspecifiedValueError(ClientCommonError, ValueError):
"""Raised when a semantic accessor sees an unspecified or unknown protobuf value.

This is also a ``ValueError`` for convenience.
"""
3 changes: 1 addition & 2 deletions src/frequenz/client/common/microgrid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@
"""Frequenz microgrid definition."""

from ._ids import EnterpriseId, MicrogridId
from ._microgrid import Microgrid, MicrogridStatus
from ._microgrid import Microgrid

__all__ = [
"EnterpriseId",
"Microgrid",
"MicrogridId",
"MicrogridStatus",
]
68 changes: 37 additions & 31 deletions src/frequenz/client/common/microgrid/_microgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,16 @@
"""Definition of a microgrid."""

import datetime
import enum
import logging
from dataclasses import dataclass
from functools import cached_property
from dataclasses import dataclass, field

from .._exception import UnspecifiedValueError
from ..grid._delivery_area import DeliveryArea
from ..types._location import Location
from ._ids import EnterpriseId, MicrogridId

_logger = logging.getLogger(__name__)


@enum.unique
class MicrogridStatus(enum.Enum):
"""The possible statuses for a microgrid."""

UNSPECIFIED = 0
"""The status is unspecified. This should not be used."""

ACTIVE = 1
"""The microgrid is active."""

INACTIVE = 2
"""The microgrid is inactive."""


@dataclass(frozen=True, kw_only=True)
class Microgrid:
class Microgrid: # pylint: disable=too-many-instance-attributes
"""A localized grouping of electricity generation, energy storage, and loads.

A microgrid is a localized grouping of electricity generation, energy storage, and
Expand Down Expand Up @@ -63,21 +45,45 @@ class Microgrid:
location: Location | None
"""Physical location of the microgrid, in geographical co-ordinates."""

status: MicrogridStatus | int
"""The current status of the microgrid."""

create_time: datetime.datetime
"""The UTC timestamp indicating when the microgrid was initially created."""

@cached_property
_active: bool | None
"""Whether the microgrid is active, or `None` if its status is unspecified."""

_allow_construction: bool = field(
default=False, repr=False, compare=False, hash=False
)
"""Internal guard allowing construction only via the `microgrid_from_proto` converter."""

def __post_init__(self) -> None:
"""Reject direct construction of this read-only type.

Raises:
TypeError: If the instance was not created via the `microgrid_from_proto`
converter.
"""
if not self._allow_construction:
raise TypeError(
f"{type(self).__name__} cannot be constructed directly; obtain "
"instances via the microgrid_from_proto converter."
)

def is_active(self) -> bool:
"""Whether the microgrid is active."""
if self.status is MicrogridStatus.UNSPECIFIED:
# Because this is a cached property, the warning will only be logged once.
_logger.warning(
"Microgrid %s has an unspecified status. Assuming it is active.", self
"""Check whether the microgrid is active.

Returns:
Whether the microgrid is active.

Raises:
UnspecifiedValueError: If the status is unspecified, so whether the
microgrid is active is unknown.
"""
if self._active is None:
raise UnspecifiedValueError(
f"status of microgrid {self} is unspecified; active state is unknown"
)
return self.status in (MicrogridStatus.ACTIVE, MicrogridStatus.UNSPECIFIED)
return self._active

def __str__(self) -> str:
"""Return the ID of this microgrid as a string."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@
UnspecifiedInverter,
)
from ._meter import Meter
from ._operational_mode import ElectricalComponentOperationalMode
from ._power_transformer import PowerTransformer
from ._precharger import Precharger
from ._problematic import (
Expand Down Expand Up @@ -80,7 +79,6 @@
"ElectricalComponentConnection",
"ElectricalComponentDiagnosticCode",
"ElectricalComponentId",
"ElectricalComponentOperationalMode",
"ElectricalComponentStateCode",
"ElectricalComponentTypes",
"Electrolyzer",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
from datetime import datetime, timezone
from typing import Any, Self

from ..._exception import UnspecifiedValueError
from ...metrics import Bounds, Metric
from ...types import Lifetime
from .. import MicrogridId
from ._category import ElectricalComponentCategory
from ._ids import ElectricalComponentId
from ._operational_mode import ElectricalComponentOperationalMode


@dataclasses.dataclass(frozen=True, kw_only=True)
Expand Down Expand Up @@ -51,14 +51,16 @@ class ElectricalComponent: # pylint: disable=too-many-instance-attributes
operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime)
"""The operational lifetime of this electrical component."""

operational_mode: ElectricalComponentOperationalMode | int = (
ElectricalComponentOperationalMode.UNSPECIFIED
)
"""The operational mode of this electrical component.
_provides_telemetry: bool | None
"""Whether this component provides telemetry data, or `None` if unspecified."""

This indicates whether the component is active and operational, and whether it
provides telemetry data, accepts control commands, or both.
"""
_accepts_control: bool | None
"""Whether this component accepts control commands, or `None` if unspecified."""

_allow_construction: bool = dataclasses.field(
default=False, repr=False, compare=False, hash=False
)
"""Internal guard allowing construction only via the `*_from_proto` converters."""

metric_config_bounds: Mapping[Metric | int, Bounds] = dataclasses.field(
default_factory=dict,
Expand All @@ -72,6 +74,10 @@ class ElectricalComponent: # pylint: disable=too-many-instance-attributes

These bounds may be derived from the component configuration, manufacturer
limits, or limits of other devices.

The keys never include `Metric.UNSPECIFIED`: such entries are dropped when
loading from protobuf. Metrics unknown to this client version may still appear
as plain `int` keys for forward-compatibility.
"""

category_specific_metadata: Mapping[str, Any] = dataclasses.field(
Expand All @@ -97,6 +103,53 @@ def __new__(cls, *_: Any, **__: Any) -> Self:
raise TypeError(f"Cannot instantiate {cls.__name__} directly")
return super().__new__(cls)

def __post_init__(self) -> None:
"""Reject direct construction of this read-only type.

Raises:
TypeError: If the instance was not created via the corresponding
`*_from_proto` converter.
"""
if not self._allow_construction:
raise TypeError(
f"{type(self).__name__} cannot be constructed directly; obtain "
"instances via the corresponding *_from_proto converter."
)

def provides_telemetry(self) -> bool:
"""Check whether this electrical component provides telemetry data.

Returns:
Whether this electrical component provides telemetry data.

Raises:
UnspecifiedValueError: If the operational mode is unspecified, so whether
telemetry is provided is unknown.
"""
if self._provides_telemetry is None:
raise UnspecifiedValueError(
f"operational mode of {self} is unspecified; "
"telemetry availability is unknown"
)
return self._provides_telemetry

def accepts_control(self) -> bool:
"""Check whether this electrical component accepts control commands.

Returns:
Whether this electrical component accepts control commands.

Raises:
UnspecifiedValueError: If the operational mode is unspecified, so whether
control commands are accepted is unknown.
"""
if self._accepts_control is None:
raise UnspecifiedValueError(
f"operational mode of {self} is unspecified; "
"control availability is unknown"
)
return self._accepts_control

def is_operational_at(self, timestamp: datetime) -> bool:
"""Check whether this electrical component is operational at a specific timestamp.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ class GridConnectionPoint(ElectricalComponent):
"""

def __post_init__(self) -> None:
"""Validate the fuse's rated current."""
"""Run the base construction gate and validate the fuse's rated current."""
super().__post_init__()
if self.rated_fuse_current < 0:
raise ValueError(
f"rated_fuse_current must be a non-negative integer, not {self.rated_fuse_current}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@
)
from ._ev_charger import ev_charger_type_from_proto, ev_charger_type_to_proto
from ._inverter import inverter_type_from_proto, inverter_type_to_proto
from ._operational_mode import (
electrical_component_operational_mode_from_proto,
electrical_component_operational_mode_to_proto,
)
from ._state_code import (
electrical_component_state_code_from_proto,
electrical_component_state_code_to_proto,
Expand All @@ -42,8 +38,6 @@
"electrical_component_diagnostic_code_to_proto",
"electrical_component_from_proto",
"electrical_component_from_proto_with_issues",
"electrical_component_operational_mode_from_proto",
"electrical_component_operational_mode_to_proto",
"electrical_component_state_code_from_proto",
"electrical_component_state_code_to_proto",
"ev_charger_type_from_proto",
Expand Down
Loading
Loading