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
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ There are a few intentional hard breaks too, all listed in the Upgrading section

* Added a new `frequenz.client.common.microgrid.Microgrid` type with a raising `is_active()` method, together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto` conversion function.

* Added a new `frequenz.client.common.microgrid.sensors.Sensor` type, with an `operational_lifetime` typed `Lifetime | InvalidLifetime` and the raising `get_operational_lifetime()`, `is_operational_at()` and `is_operational_now()` accessors, together with the `frequenz.client.common.microgrid.sensors.proto.v1alpha8.sensor_from_proto` conversion function.

* 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` class hierarchy, including `v1alpha8` proto conversion functions.

The class of a component is its identity; components don't carry category or type attributes. The only exceptions are the error-recovery classes `UnrecognizedElectricalComponent` and `MismatchedCategoryElectricalComponent` (with a raw protobuf `category` value) and `UnrecognizedBattery`, `UnrecognizedInverter` and `UnrecognizedEvCharger` (with a raw protobuf `type` value), which preserve the raw protobuf values received from the protocol version used to load them.
Expand Down
9 changes: 7 additions & 2 deletions docs/user-guide/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,13 @@ See the [API Reference](../reference/frequenz/client/common/index.md) for the co

## Sensors

This namespace provides [`SensorId`][frequenz.client.common.microgrid.sensors.SensorId]
for sensor identities.
Use [`Sensor`][frequenz.client.common.microgrid.sensors.Sensor] for a sensor
that measures a physical metric in the microgrid's surroundings, with
[`SensorId`][frequenz.client.common.microgrid.sensors.SensorId] for sensor
identities. Its operational lifetime is a
[`Lifetime`][frequenz.client.common.microgrid.Lifetime] or an
[`InvalidLifetime`][frequenz.client.common.microgrid.InvalidLifetime], resolved
by `get_operational_lifetime()`.

## Common types

Expand Down
2 changes: 2 additions & 0 deletions src/frequenz/client/common/microgrid/sensors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"""Frequenz microgrid sensors definition."""

from ._id import SensorId
from ._sensor import Sensor

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

"""Definition of a microgrid sensor."""

from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import assert_never

from .._ids import MicrogridId
from .._lifetime import InvalidLifetime, InvalidLifetimeError, Lifetime
from ._id import SensorId


@dataclass(frozen=True, kw_only=True)
class Sensor:
"""A sensor that measures a physical metric in the microgrid's surroundings.

Sensors are not part of the electrical infrastructure but provide
environmental data such as temperature, humidity, and solar irradiance.
"""

id: SensorId
"""The unique identifier of the sensor."""

microgrid_id: MicrogridId
"""The unique identifier of the parent microgrid."""

name: str
"""The name of the sensor.

An empty string when the wire did not set it.
"""

model: str
"""The model of the sensor.

This includes both the manufacturer and the model name.
"""

operational_lifetime: Lifetime | InvalidLifetime = field(default_factory=Lifetime)
"""The operational lifetime of the sensor.

An [`InvalidLifetime`][....InvalidLifetime] preserves malformed wire data.

Tip:
Prefer [`get_operational_lifetime()`][..get_operational_lifetime] when
a valid lifetime is required.
"""

_allow_construction: bool = field(
default=False, repr=False, compare=False, hash=False
)
"""Internal guard allowing construction only via the `sensor_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
[`sensor_from_proto`][...proto.v1alpha8.sensor_from_proto]
converter.
"""
if not self._allow_construction:
raise TypeError(
f"{type(self).__name__} cannot be constructed directly; obtain "
"instances via the sensor_from_proto converter."
)

def get_operational_lifetime(self) -> Lifetime:
"""Return the operational lifetime as a valid `Lifetime`.

Returns:
The valid operational lifetime.

Raises:
InvalidLifetimeError: If malformed lifetime data was received. The
offending value is available on the exception's `lifetime`
attribute.
"""
match self.operational_lifetime:
case InvalidLifetime() as invalid:
raise InvalidLifetimeError(self, "operational_lifetime", invalid)
case Lifetime() as valid:
return valid
case unknown:
assert_never(unknown)

def is_operational_at(self, timestamp: datetime) -> bool: # noqa: DOC502
"""Check whether this sensor is operational at a specific timestamp.

Args:
timestamp: The timestamp to check.

Returns:
Whether this sensor is operational at the given timestamp.

Raises:
InvalidLifetimeError: If malformed lifetime data was received. The
offending value is available on the exception's `lifetime`
attribute.
"""
return self.get_operational_lifetime().is_operational_at(timestamp)

def is_operational_now(self) -> bool: # noqa: DOC502
"""Check whether this sensor is currently operational.

Returns:
Whether this sensor is operational at the current time.

Raises:
InvalidLifetimeError: If malformed lifetime data was received. The
offending value is available on the exception's `lifetime`
attribute.
"""
return self.is_operational_at(datetime.now(timezone.utc))

@property
def identity(self) -> tuple[SensorId, MicrogridId]:
"""The identity of this sensor.

This uses the sensor ID and microgrid ID to identify a sensor without
considering the other attributes, so even if a sensor state changed, the
identity remains the same.
"""
return (self.id, self.microgrid_id)

def __str__(self) -> str:
"""Return the ID of this sensor as a string, followed by its name if any."""
name = f":{self.name}" if self.name else ""
return f"{self.id}{name}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Microgrid sensor objects from/to proto conversion functions."""
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Conversion of microgrid sensor objects from/to protobuf v1alpha8."""

from ._sensor import sensor_from_proto

__all__ = [
"sensor_from_proto",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Loading of Sensor objects from protobuf messages."""

from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2

from ...._ids import MicrogridId
from ...._lifetime import InvalidLifetime, Lifetime
from ....proto.v1alpha8._lifetime import lifetime_from_proto
from ..._id import SensorId
from ..._sensor import Sensor


def sensor_from_proto(message: sensors_pb2.Sensor) -> Sensor:
"""Convert a protobuf message to a [`Sensor`][....Sensor] object.

Malformed input is surfaced through the returned object rather than a side
channel: a malformed operational lifetime becomes an
[`InvalidLifetime`][.....InvalidLifetime]. A missing operational lifetime
becomes an unbounded [`Lifetime`][.....Lifetime].

Args:
message: The protobuf message to convert.

Returns:
The corresponding [`Sensor`][....Sensor] object.
"""
operational_lifetime: Lifetime | InvalidLifetime = Lifetime()
if message.HasField("operational_lifetime"):
operational_lifetime = lifetime_from_proto(message.operational_lifetime)

return Sensor(
id=SensorId(message.id),
microgrid_id=MicrogridId(message.microgrid_id),
name=message.name,
model=message.model,
Comment thread
llucax marked this conversation as resolved.
operational_lifetime=operational_lifetime,
_allow_construction=True,
)
4 changes: 4 additions & 0 deletions tests/microgrid/sensors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Tests for the microgrid.sensors package."""
4 changes: 4 additions & 0 deletions tests/microgrid/sensors/proto/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Tests for the microgrid.sensors.proto package."""
4 changes: 4 additions & 0 deletions tests/microgrid/sensors/proto/v1alpha8/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Tests for the microgrid.sensors.proto.v1alpha8 package."""
135 changes: 135 additions & 0 deletions tests/microgrid/sensors/proto/v1alpha8/test_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# License: MIT
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH

"""Tests for the Sensor protobuf conversion."""

from datetime import datetime, timezone
from unittest.mock import Mock, patch

import pytest
from frequenz.api.common.v1alpha8.microgrid import lifetime_pb2
from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2

# pylint: disable-next=no-name-in-module
from google.protobuf.timestamp_pb2 import Timestamp

from frequenz.client.common.microgrid import (
InvalidLifetime,
InvalidLifetimeError,
Lifetime,
MicrogridId,
)
from frequenz.client.common.microgrid.sensors import Sensor, SensorId
from frequenz.client.common.microgrid.sensors.proto.v1alpha8 import (
sensor_from_proto,
)


def test_from_proto_all_fields() -> None:
"""Every wire field is converted into the wrapper."""
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
proto = sensors_pb2.Sensor(
id=1234,
microgrid_id=5678,
name="Test Sensor",
model="ACME Thermometer",
operational_lifetime=lifetime_pb2.Lifetime(
start_timestamp=Timestamp(seconds=int(start.timestamp()))
),
)

sensor = sensor_from_proto(proto)

assert isinstance(sensor, Sensor)
assert sensor.id == SensorId(1234)
assert sensor.microgrid_id == MicrogridId(5678)
assert sensor.name == "Test Sensor"
assert sensor.model == "ACME Thermometer"
assert sensor.operational_lifetime == Lifetime(start_time=start)
assert sensor.get_operational_lifetime() == Lifetime(start_time=start)


def test_from_proto_empty_strings() -> None:
"""Unset string fields are kept as empty strings."""
sensor = sensor_from_proto(sensors_pb2.Sensor(id=1, microgrid_id=2))

assert sensor.name == ""
assert sensor.model == ""


def test_from_proto_missing_lifetime_is_unbounded() -> None:
"""A missing operational lifetime becomes an unbounded `Lifetime`."""
sensor = sensor_from_proto(sensors_pb2.Sensor(id=1, microgrid_id=2))

assert sensor.operational_lifetime == Lifetime()
assert sensor.is_operational_now() is True


def test_from_proto_empty_lifetime_is_unbounded() -> None:
"""A present but empty operational lifetime becomes an unbounded `Lifetime`."""
proto = sensors_pb2.Sensor(
id=1, microgrid_id=2, operational_lifetime=lifetime_pb2.Lifetime()
)

sensor = sensor_from_proto(proto)

assert sensor.operational_lifetime == Lifetime()


@patch(
"frequenz.client.common.microgrid.sensors.proto.v1alpha8._sensor.lifetime_from_proto"
)
def test_from_proto_delegates_lifetime(mock_lifetime_from_proto: Mock) -> None:
"""The lifetime conversion is delegated to `lifetime_from_proto`."""
lifetime = Lifetime()
mock_lifetime_from_proto.return_value = lifetime
proto = sensors_pb2.Sensor(
id=1, microgrid_id=2, operational_lifetime=lifetime_pb2.Lifetime()
)

sensor = sensor_from_proto(proto)

mock_lifetime_from_proto.assert_called_once_with(proto.operational_lifetime)
assert sensor.operational_lifetime is lifetime


@patch(
"frequenz.client.common.microgrid.sensors.proto.v1alpha8._sensor.lifetime_from_proto"
)
def test_from_proto_missing_lifetime_skips_delegation(
mock_lifetime_from_proto: Mock,
) -> None:
"""A missing lifetime does not call `lifetime_from_proto`."""
sensor_from_proto(sensors_pb2.Sensor(id=1, microgrid_id=2))

mock_lifetime_from_proto.assert_not_called()


@pytest.mark.parametrize(
"lifetime",
[
pytest.param(
lifetime_pb2.Lifetime(
start_timestamp=Timestamp(seconds=200),
end_timestamp=Timestamp(seconds=100),
),
id="reversed-range",
),
pytest.param(
lifetime_pb2.Lifetime(start_timestamp=Timestamp(seconds=0, nanos=-1)),
id="negative-nanos",
),
],
)
def test_from_proto_malformed_lifetime_is_preserved(
lifetime: lifetime_pb2.Lifetime,
) -> None:
"""A malformed operational lifetime is preserved as an `InvalidLifetime`."""
proto = sensors_pb2.Sensor(id=1, microgrid_id=2, operational_lifetime=lifetime)

sensor = sensor_from_proto(proto)

assert isinstance(sensor.operational_lifetime, InvalidLifetime)
with pytest.raises(InvalidLifetimeError) as exc_info:
sensor.get_operational_lifetime()
assert exc_info.value.lifetime is sensor.operational_lifetime
Loading