Skip to content

Commit 9ed76fe

Browse files
committed
Add initial implementation of electrical component types and related utilities
This commit introduces a comprehensive set of classes and functions for managing electrical components within the microgrid framework. Key additions include: - Definitions for various electrical components such as batteries, inverters, chargers, and more, each with specific attributes and categories. - Protobuf message loading functions to convert messages into corresponding Python objects. - JSON encoding utilities for serializing component data for API interactions. These changes lay the groundwork for enhanced functionality and integration within the microgrid system. Signed-off-by: eduardiazf <[email protected]>
1 parent a9d399e commit 9ed76fe

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2619
-0
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Delivery area information for the energy market."""
5+
6+
import enum
7+
from dataclasses import dataclass
8+
9+
from frequenz.api.common.v1alpha8.grid import delivery_area_pb2
10+
11+
12+
@enum.unique
13+
class EnergyMarketCodeType(enum.Enum):
14+
"""The identification code types used in the energy market.
15+
16+
CodeType specifies the type of identification code used for uniquely
17+
identifying various entities such as delivery areas, market participants,
18+
and grid components within the energy market.
19+
20+
This enumeration aims to
21+
offer compatibility across different jurisdictional standards.
22+
23+
Note: Understanding Code Types
24+
Different regions or countries may have their own standards for uniquely
25+
identifying various entities within the energy market. For example, in
26+
Europe, the Energy Identification Code (EIC) is commonly used for this
27+
purpose.
28+
29+
Note: Extensibility
30+
New code types can be added to this enum to accommodate additional regional
31+
standards, enhancing the API's adaptability.
32+
33+
Danger: Validation Required
34+
The chosen code type should correspond correctly with the `code` field in
35+
the relevant message objects, such as `DeliveryArea` or `Counterparty`.
36+
Failure to match the code type with the correct code could lead to
37+
processing errors.
38+
"""
39+
40+
UNSPECIFIED = delivery_area_pb2.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED
41+
"""Unspecified type. This value is a placeholder and should not be used."""
42+
43+
EUROPE_EIC = delivery_area_pb2.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC
44+
"""European Energy Identification Code Standard."""
45+
46+
US_NERC = delivery_area_pb2.ENERGY_MARKET_CODE_TYPE_US_NERC
47+
"""North American Electric Reliability Corporation identifiers."""
48+
49+
50+
@dataclass(frozen=True, kw_only=True)
51+
class DeliveryArea:
52+
"""A geographical or administrative region where electricity deliveries occur.
53+
54+
DeliveryArea represents the geographical or administrative region, usually defined
55+
and maintained by a Transmission System Operator (TSO), where electricity deliveries
56+
for a contract occur.
57+
58+
The concept is important to energy trading as it delineates the agreed-upon delivery
59+
location. Delivery areas can have different codes based on the jurisdiction in
60+
which they operate.
61+
62+
Note: Jurisdictional Differences
63+
This is typically represented by specific codes according to local jurisdiction.
64+
65+
In Europe, this is represented by an
66+
[EIC](https://en.wikipedia.org/wiki/Energy_Identification_Code) (Energy
67+
Identification Code). [List of
68+
EICs](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/).
69+
"""
70+
71+
code: str | None
72+
"""The code representing the unique identifier for the delivery area."""
73+
74+
code_type: EnergyMarketCodeType | int
75+
"""Type of code used for identifying the delivery area itself.
76+
77+
This code could be extended in the future, in case an unknown code type is
78+
encountered, a plain integer value is used to represent it.
79+
"""
80+
81+
def __str__(self) -> str:
82+
"""Return a human-readable string representation of this instance."""
83+
code = self.code or "<NO CODE>"
84+
code_type = (
85+
f"type={self.code_type}"
86+
if isinstance(self.code_type, int)
87+
else self.code_type.name
88+
)
89+
return f"{code}[{code_type}]"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Loading of DeliveryArea objects from protobuf messages."""
5+
6+
import logging
7+
8+
from frequenz.api.common.v1alpha8.grid import delivery_area_pb2
9+
from frequenz.client.common import enum_proto
10+
11+
from ._delivery_area import DeliveryArea, EnergyMarketCodeType
12+
13+
_logger = logging.getLogger(__name__)
14+
15+
16+
def delivery_area_from_proto(message: delivery_area_pb2.DeliveryArea) -> DeliveryArea:
17+
"""Convert a protobuf delivery area message to a delivery area object.
18+
19+
Args:
20+
message: The protobuf message to convert.
21+
22+
Returns:
23+
The resulting delivery area object.
24+
"""
25+
issues: list[str] = []
26+
27+
code = message.code or None
28+
if code is None:
29+
issues.append("code is empty")
30+
31+
code_type = enum_proto.enum_from_proto(message.code_type, EnergyMarketCodeType)
32+
if code_type is EnergyMarketCodeType.UNSPECIFIED:
33+
issues.append("code_type is unspecified")
34+
elif isinstance(code_type, int):
35+
issues.append("code_type is unrecognized")
36+
37+
if issues:
38+
_logger.warning(
39+
"Found issues in delivery area: %s | Protobuf message:\n%s",
40+
", ".join(issues),
41+
message,
42+
)
43+
44+
return DeliveryArea(code=code, code_type=code_type)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Lifetime of a microgrid asset."""
5+
6+
7+
from dataclasses import dataclass
8+
from datetime import datetime, timezone
9+
10+
11+
@dataclass(frozen=True, kw_only=True)
12+
class Lifetime:
13+
"""An active operational period of a microgrid asset.
14+
15+
Warning:
16+
The [`end`][frequenz.client.microgrid.Lifetime.end] timestamp indicates that the
17+
asset has been permanently removed from the system.
18+
"""
19+
20+
start: datetime | None = None
21+
"""The moment when the asset became operationally active.
22+
23+
If `None`, the asset is considered to be active in any past moment previous to the
24+
[`end`][frequenz.client.microgrid.Lifetime.end].
25+
"""
26+
27+
end: datetime | None = None
28+
"""The moment when the asset's operational activity ceased.
29+
30+
If `None`, the asset is considered to be active with no plans to be deactivated.
31+
"""
32+
33+
def __post_init__(self) -> None:
34+
"""Validate this lifetime."""
35+
if self.start is not None and self.end is not None and self.start > self.end:
36+
raise ValueError(
37+
f"Start ({self.start}) must be before or equal to end ({self.end})"
38+
)
39+
40+
def is_operational_at(self, timestamp: datetime) -> bool:
41+
"""Check whether this lifetime is active at a specific timestamp."""
42+
# Handle start time - it's not active if start is in the future
43+
if self.start is not None and self.start > timestamp:
44+
return False
45+
# Handle end time - active up to and including end time
46+
if self.end is not None:
47+
return self.end >= timestamp
48+
# self.end is None, and either self.start is None or self.start <= timestamp,
49+
# so it is active at this timestamp
50+
return True
51+
52+
def is_operational_now(self) -> bool:
53+
"""Whether this lifetime is currently active."""
54+
return self.is_operational_at(datetime.now(timezone.utc))
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Loading of Lifetime objects from protobuf messages."""
5+
6+
from frequenz.api.common.v1alpha8.microgrid import lifetime_pb2
7+
from frequenz.client.base.conversion import to_datetime
8+
9+
from ._lifetime import Lifetime
10+
11+
12+
def lifetime_from_proto(
13+
message: lifetime_pb2.Lifetime,
14+
) -> Lifetime:
15+
"""Create a [`Lifetime`][frequenz.client.microgrid.Lifetime] from a protobuf message."""
16+
start = (
17+
to_datetime(message.start_timestamp)
18+
if message.HasField("start_timestamp")
19+
else None
20+
)
21+
end = (
22+
to_datetime(message.end_timestamp)
23+
if message.HasField("end_timestamp")
24+
else None
25+
)
26+
return Lifetime(start=start, end=end)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Location information for a microgrid."""
5+
6+
7+
from dataclasses import dataclass
8+
9+
10+
@dataclass(frozen=True, kw_only=True)
11+
class Location:
12+
"""A location of a microgrid."""
13+
14+
latitude: float | None
15+
"""The latitude of the microgrid in degree."""
16+
17+
longitude: float | None
18+
"""The longitude of the microgrid in degree."""
19+
20+
country_code: str | None
21+
"""The country code of the microgrid in ISO 3166-1 Alpha 2 format."""
22+
23+
def __str__(self) -> str:
24+
"""Return the short string representation of this instance."""
25+
country = self.country_code or "<NO COUNTRY CODE>"
26+
lat = f"{self.latitude:.2f}" if self.latitude is not None else "?"
27+
lon = f"{self.longitude:.2f}" if self.longitude is not None else "?"
28+
coordinates = ""
29+
if self.latitude is not None or self.longitude is not None:
30+
coordinates = f":({lat}, {lon})"
31+
return f"{country}{coordinates}"
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Loading of Location objects from protobuf messages."""
5+
6+
import logging
7+
8+
from frequenz.api.common.v1alpha8.types import location_pb2
9+
10+
from ._location import Location
11+
12+
_logger = logging.getLogger(__name__)
13+
14+
15+
def location_from_proto(message: location_pb2.Location) -> Location:
16+
"""Convert a protobuf location message to a location object.
17+
18+
Args:
19+
message: The protobuf message to convert.
20+
21+
Returns:
22+
The resulting location object.
23+
"""
24+
issues: list[str] = []
25+
26+
latitude: float | None = message.latitude if -90 <= message.latitude <= 90 else None
27+
if latitude is None:
28+
issues.append("latitude out of range [-90, 90]")
29+
30+
longitude: float | None = (
31+
message.longitude if -180 <= message.longitude <= 180 else None
32+
)
33+
if longitude is None:
34+
issues.append("longitude out of range [-180, 180]")
35+
36+
country_code = message.country_code or None
37+
if country_code is None:
38+
issues.append("country code is empty")
39+
40+
if issues:
41+
_logger.warning(
42+
"Found issues in location: %s | Protobuf message:\n%s",
43+
", ".join(issues),
44+
message,
45+
)
46+
47+
return Location(latitude=latitude, longitude=longitude, country_code=country_code)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Definition of a microgrid."""
5+
6+
import datetime
7+
import enum
8+
import logging
9+
from dataclasses import dataclass
10+
from functools import cached_property
11+
12+
from frequenz.api.common.v1alpha8.microgrid import microgrid_pb2
13+
from frequenz.client.common.microgrid import EnterpriseId, MicrogridId
14+
15+
from ._delivery_area import DeliveryArea
16+
from ._location import Location
17+
18+
_logger = logging.getLogger(__name__)
19+
20+
21+
@enum.unique
22+
class MicrogridStatus(enum.Enum):
23+
"""The possible statuses for a microgrid."""
24+
25+
UNSPECIFIED = microgrid_pb2.MICROGRID_STATUS_UNSPECIFIED
26+
"""The status is unspecified. This should not be used."""
27+
28+
ACTIVE = microgrid_pb2.MICROGRID_STATUS_ACTIVE
29+
"""The microgrid is active."""
30+
31+
INACTIVE = microgrid_pb2.MICROGRID_STATUS_INACTIVE
32+
"""The microgrid is inactive."""
33+
34+
35+
@dataclass(frozen=True, kw_only=True)
36+
class Microgrid:
37+
"""A localized grouping of electricity generation, energy storage, and loads.
38+
39+
A microgrid is a localized grouping of electricity generation, energy storage, and
40+
loads that normally operates connected to a traditional centralized grid.
41+
42+
Each microgrid has a unique identifier and is associated with an enterprise account.
43+
44+
A key feature is that it has a physical location and is situated in a delivery area.
45+
46+
Note: Key Concepts
47+
- Physical Location: Geographical coordinates specify the exact physical
48+
location of the microgrid.
49+
- Delivery Area: Each microgrid is part of a broader delivery area, which is
50+
crucial for energy trading and compliance.
51+
"""
52+
53+
id: MicrogridId
54+
"""The unique identifier of the microgrid."""
55+
56+
enterprise_id: EnterpriseId
57+
"""The unique identifier linking this microgrid to its parent enterprise account."""
58+
59+
name: str | None
60+
"""Name of the microgrid."""
61+
62+
delivery_area: DeliveryArea | None
63+
"""The delivery area where the microgrid is located, as identified by a specific code."""
64+
65+
location: Location | None
66+
"""Physical location of the microgrid, in geographical co-ordinates."""
67+
68+
status: MicrogridStatus | int
69+
"""The current status of the microgrid."""
70+
71+
create_timestamp: datetime.datetime
72+
"""The UTC timestamp indicating when the microgrid was initially created."""
73+
74+
@cached_property
75+
def is_active(self) -> bool:
76+
"""Whether the microgrid is active."""
77+
if self.status is MicrogridStatus.UNSPECIFIED:
78+
# Because this is a cached property, the warning will only be logged once.
79+
_logger.warning(
80+
"Microgrid %s has an unspecified status. Assuming it is active.", self
81+
)
82+
return self.status in (MicrogridStatus.ACTIVE, MicrogridStatus.UNSPECIFIED)
83+
84+
def __str__(self) -> str:
85+
"""Return the ID of this microgrid as a string."""
86+
name = f":{self.name}" if self.name else ""
87+
return f"{self.id}{name}"

0 commit comments

Comments
 (0)