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
24 changes: 24 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,30 @@ That said, if you want to test the actual website in **your fork**, you can
always use `mike deploy --push --remote your-fork-remote`, and then access the
GitHub pages produced for your fork.

## Wrapper conventions

### Field names and docstrings

The following rules apply when wrapping protobuf messages into idiomatic Python
types:

1. **Alignment**: By default, wrapper field names match the protobuf field names
unless a clear Pythonic improvement preserves or clarifies semantics.
2. **IDs**: Always keep the `_id` suffix for fields representing identifiers
(e.g., `id`, `microgrid_id`, `enterprise_id`, `source_id`, `destination_id`).
3. **Redundancy**: You may drop a redundant or long entity prefix while keeping
`_id`. For example, `source_electrical_component_id` becomes `source_id`.
4. **Time fields**: Use the `_time` suffix for both protobuf `_time` and
`_timestamp` fields. Never drop the suffix, as bare names like `start` or
`create` can be read as verbs or actions. For example, `create_timestamp`
becomes `create_time` and `start_timestamp` becomes `start_time`.
5. **Values**: You may drop the `_value` suffix inside a class ending in `Value`
when the remaining name remains clear. For example, `avg`, `min`, `max`, and
`raw` in `AggregatedMetricValue`.
6. **Docstrings**: Docstrings may be shorter or more Pythonic than the protobuf
comments, but they must not contradict, narrow, broaden, or operationally
reinterpret the protobuf semantics.

## Releasing

These are the steps to create a new release:
Expand Down
1 change: 1 addition & 0 deletions src/frequenz/client/common/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ for downstream users, they don't wrap protobuf messages.

## CORE RULES

- **Wrapper field names and docstrings follow the rules in [CONTRIBUTING.md](../../../../CONTRIBUTING.md).**
- **Public symbols live in `_name.py`, exported via the package `__init__.py`.** External
importers never use the underscore module path.
- **Internal cross-module imports are ALWAYS relative and use the real symbol
Expand Down
2 changes: 1 addition & 1 deletion src/frequenz/client/common/microgrid/_microgrid.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ class Microgrid:
status: MicrogridStatus | int
"""The current status of the microgrid."""

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

@cached_property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
from ._state_code import ElectricalComponentStateCode
from ._steam_boiler import SteamBoiler
from ._types import (
ComponentTypes,
ElectricalComponentTypes,
ProblematicElectricalComponentTypes,
UnrecognizedElectricalComponentTypes,
UnspecifiedElectricalComponentTypes,
Expand All @@ -71,7 +71,6 @@
"BatteryTypes",
"Breaker",
"Chp",
"ComponentTypes",
"Converter",
"CryptoMiner",
"DcEvCharger",
Expand All @@ -81,6 +80,7 @@
"ElectricalComponentDiagnosticCode",
"ElectricalComponentId",
"ElectricalComponentStateCode",
"ElectricalComponentTypes",
"Electrolyzer",
"EvCharger",
"EvChargerType",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ class ElectricalComponentConnection:
when and how the microgrid infrastructure has been modified.
"""

source: ElectricalComponentId
source_id: ElectricalComponentId
"""The unique identifier of the electrical component where the connection originates.

This is aligned with the direction of current flow away from the grid connection
point, or in case of islands, away from the islanding point.
"""

destination: ElectricalComponentId
destination_id: ElectricalComponentId
"""The unique ID of the electrical component where the connection terminates.

This is the electrical component towards which the current flows.
Expand All @@ -55,7 +55,7 @@ class ElectricalComponentConnection:

def __post_init__(self) -> None:
"""Ensure that the source and destination electrical components are different."""
if self.source == self.destination:
if self.source_id == self.destination_id:
raise ValueError("Source and destination components must be different")

def is_operational_at(self, timestamp: datetime) -> bool:
Expand All @@ -68,4 +68,4 @@ def is_operational_now(self) -> bool:

def __str__(self) -> str:
"""Return a human-readable string representation of this instance."""
return f"{self.source}->{self.destination}"
return f"{self.source_id}->{self.destination_id}"
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
)
"""All possible electrical component types that have a problem."""

ComponentTypes: TypeAlias = (
ElectricalComponentTypes: TypeAlias = (
BatteryTypes
| Breaker
| Chp
Expand All @@ -66,4 +66,4 @@
| SteamBoiler
| WindTurbine
)
"""All possible component types."""
"""All possible electrical component types."""
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
BatteryType,
Breaker,
Chp,
ComponentTypes,
Converter,
CryptoMiner,
DcEvCharger,
ElectricalComponentCategory,
ElectricalComponentId,
ElectricalComponentTypes,
Electrolyzer,
EvChargerType,
GridConnectionPoint,
Expand Down Expand Up @@ -66,7 +66,7 @@

def electrical_component_from_proto(
message: electrical_components_pb2.ElectricalComponent,
) -> ComponentTypes:
) -> ElectricalComponentTypes:
"""Convert a protobuf message to an electrical component instance.

Args:
Expand Down Expand Up @@ -200,7 +200,7 @@ def electrical_component_from_proto_with_issues(
*,
major_issues: list[str],
minor_issues: list[str],
) -> ComponentTypes:
) -> ElectricalComponentTypes:
"""Convert a protobuf message to an electrical component and collect issues.

Args:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ def electrical_component_connection_from_proto_with_issues(
)

return ElectricalComponentConnection(
source=source_component_id,
destination=destination_component_id,
source_id=source_component_id,
destination_id=destination_component_id,
operational_lifetime=lifetime,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,13 @@ def microgrid_from_proto(message: microgrid_pb2.Microgrid) -> Microgrid:
message,
)

# The wrapper uses create_time, but the protobuf field remains create_timestamp.
return Microgrid(
id=MicrogridId(message.id),
enterprise_id=EnterpriseId(message.enterprise_id),
name=message.name or None,
delivery_area=delivery_area,
location=location,
status=status,
create_timestamp=datetime_from_proto(message.create_timestamp),
create_time=datetime_from_proto(message.create_timestamp),
)
33 changes: 19 additions & 14 deletions src/frequenz/client/common/types/_lifetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,45 @@ class Lifetime:
"""An active operational period of an asset.

Warning:
The [`end`][.end] timestamp indicates that the asset has been permanently
removed from service.
The [`end_time`][.end_time] timestamp indicates that the asset has been
permanently removed from service.
"""

start: datetime | None = None
start_time: datetime | None = None
"""The moment when the asset became operationally active.

If `None`, the asset is considered to be active in any past moment previous to the
[`end`][..end].
[`end_time`][..end_time].
"""

end: datetime | None = None
end_time: datetime | None = None
"""The moment when the asset's operational activity ceased.

If `None`, the asset is considered to be active with no plans to be deactivated.
"""

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:
if (
self.start_time is not None
and self.end_time is not None
and self.start_time > self.end_time
):
raise ValueError(
f"Start ({self.start}) must be before or equal to end ({self.end})"
f"Start ({self.start_time}) must be before or equal to end "
f"({self.end_time})"
)

def is_operational_at(self, timestamp: datetime) -> bool:
"""Check whether this lifetime is active at a specific timestamp."""
# Handle start time - it's not active if start is in the future
if self.start is not None and self.start > timestamp:
# Handle start time - it's not active if start_time is in the future
if self.start_time is not None and self.start_time > timestamp:
return False
# Handle end time - active up to and including end time
if self.end is not None:
return self.end >= timestamp
# self.end is None, and either self.start is None or self.start <= timestamp,
# so it is active at this timestamp
# Handle end time - active up to and including end_time
if self.end_time is not None:
return self.end_time >= timestamp
# self.end_time is None, and either self.start_time is None or
# self.start_time <= timestamp, so it is active at this timestamp
return True

def is_operational_now(self) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ def lifetime_from_proto(
if message.HasField("end_timestamp")
else None
)
return Lifetime(start=start, end=end)
return Lifetime(start_time=start, end_time=end)
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
from frequenz.client.common.types import Lifetime

DEFAULT_LIFETIME = Lifetime(
start=datetime(2020, 1, 1, tzinfo=timezone.utc),
end=datetime(2030, 1, 1, tzinfo=timezone.utc),
start_time=datetime(2020, 1, 1, tzinfo=timezone.utc),
end_time=datetime(2030, 1, 1, tzinfo=timezone.utc),
)
DEFAULT_COMPONENT_ID = ElectricalComponentId(42)
DEFAULT_MICROGRID_ID = MicrogridId(1)
Expand Down Expand Up @@ -101,12 +101,14 @@ def base_data_as_proto(
)
if base_data.lifetime:
lifetime_dict: dict[str, Timestamp] = {}
if base_data.lifetime.start is not None:
if base_data.lifetime.start_time is not None:
lifetime_dict["start_timestamp"] = datetime_to_proto(
base_data.lifetime.start
base_data.lifetime.start_time
)
if base_data.lifetime.end_time is not None:
lifetime_dict["end_timestamp"] = datetime_to_proto(
base_data.lifetime.end_time
)
if base_data.lifetime.end is not None:
lifetime_dict["end_timestamp"] = datetime_to_proto(base_data.lifetime.end)
proto.operational_lifetime.CopyFrom(lifetime_pb2.Lifetime(**lifetime_dict))
if base_data.rated_bounds:
for metric, bounds in base_data.rated_bounds.items():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ def test_success(proto_data: dict[str, Any], expected_minor_issues: list[str]) -
assert connection is not None
assert not major_issues
assert minor_issues == expected_minor_issues
assert connection.source == ElectricalComponentId(
assert connection.source_id == ElectricalComponentId(
proto_data["source_electrical_component_id"]
)
assert connection.destination == ElectricalComponentId(
assert connection.destination_id == ElectricalComponentId(
proto_data["destination_electrical_component_id"]
)

Expand Down Expand Up @@ -131,8 +131,8 @@ def test_invalid_lifetime(mock_lifetime_from_proto: Mock) -> None:
)

assert connection is not None
assert connection.source == ElectricalComponentId(1)
assert connection.destination == ElectricalComponentId(2)
assert connection.source_id == ElectricalComponentId(1)
assert connection.destination_id == ElectricalComponentId(2)
assert major_issues == [
"invalid operational lifetime (Invalid lifetime), considering it as missing "
"(i.e. always operational)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@
def test_creation() -> None:
"""Test basic ElectricalComponentConnection creation and validation."""
now = datetime.now(timezone.utc)
lifetime = Lifetime(start=now)
lifetime = Lifetime(start_time=now)
connection = ElectricalComponentConnection(
source=ElectricalComponentId(1),
destination=ElectricalComponentId(2),
source_id=ElectricalComponentId(1),
destination_id=ElectricalComponentId(2),
operational_lifetime=lifetime,
)

assert connection.source == ElectricalComponentId(1)
assert connection.destination == ElectricalComponentId(2)
assert connection.source_id == ElectricalComponentId(1)
assert connection.destination_id == ElectricalComponentId(2)
assert connection.operational_lifetime == lifetime


Expand All @@ -36,34 +36,34 @@ def test_validation() -> None:
ValueError, match="Source and destination components must be different"
):
ElectricalComponentConnection(
source=ElectricalComponentId(1), destination=ElectricalComponentId(1)
source_id=ElectricalComponentId(1), destination_id=ElectricalComponentId(1)
)


def test_str() -> None:
"""Test string representation of ElectricalComponentConnection."""
connection = ElectricalComponentConnection(
source=ElectricalComponentId(1), destination=ElectricalComponentId(2)
source_id=ElectricalComponentId(1), destination_id=ElectricalComponentId(2)
)
assert str(connection) == "CID1->CID2"


def test_equality_and_hash() -> None:
"""Test equality and hashing of the frozen ElectricalComponentConnection."""
lifetime = Lifetime(start=datetime(2025, 1, 1, tzinfo=timezone.utc))
lifetime = Lifetime(start_time=datetime(2025, 1, 1, tzinfo=timezone.utc))
connection = ElectricalComponentConnection(
source=ElectricalComponentId(1),
destination=ElectricalComponentId(2),
source_id=ElectricalComponentId(1),
destination_id=ElectricalComponentId(2),
operational_lifetime=lifetime,
)
same = ElectricalComponentConnection(
source=ElectricalComponentId(1),
destination=ElectricalComponentId(2),
source_id=ElectricalComponentId(1),
destination_id=ElectricalComponentId(2),
operational_lifetime=lifetime,
)
different = ElectricalComponentConnection(
source=ElectricalComponentId(1),
destination=ElectricalComponentId(3),
source_id=ElectricalComponentId(1),
destination_id=ElectricalComponentId(3),
operational_lifetime=lifetime,
)

Expand All @@ -78,9 +78,9 @@ def test_is_operational_at_boundaries() -> None:
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = datetime(2025, 12, 31, tzinfo=timezone.utc)
connection = ElectricalComponentConnection(
source=ElectricalComponentId(1),
destination=ElectricalComponentId(2),
operational_lifetime=Lifetime(start=start, end=end),
source_id=ElectricalComponentId(1),
destination_id=ElectricalComponentId(2),
operational_lifetime=Lifetime(start_time=start, end_time=end),
)

before = start - timedelta(seconds=1)
Expand All @@ -103,8 +103,8 @@ def test_is_operational_at(lifetime_active: bool) -> None:
mock_lifetime.is_operational_at.return_value = lifetime_active

connection = ElectricalComponentConnection(
source=ElectricalComponentId(1),
destination=ElectricalComponentId(2),
source_id=ElectricalComponentId(1),
destination_id=ElectricalComponentId(2),
operational_lifetime=mock_lifetime,
)

Expand All @@ -128,8 +128,8 @@ def test_is_operational_now(mock_datetime: Mock, lifetime_active: bool) -> None:
mock_lifetime.is_operational_at.return_value = lifetime_active

connection = ElectricalComponentConnection(
source=ElectricalComponentId(1),
destination=ElectricalComponentId(2),
source_id=ElectricalComponentId(1),
destination_id=ElectricalComponentId(2),
operational_lifetime=mock_lifetime,
)

Expand Down
Loading
Loading