Skip to content

Commit 10bd74c

Browse files
committed
Implement GetMicrogridMetadata
Implement the `GetMicrogridMetadata` RPC call in the `MicrogridApiClient`. To do this we also need to implement the data structures that will be used to wrap the data returned by the API. These wrappers use a more Pythonic approach than the protobuf messages. For example, if some fields don't come in the serialized data, a `None` will be used instead of using the protobuf defaults. This is to avoid ending up exposing wrong data (for example, if we don't have location information, it doesn't make sense to tell the user we are located at latitude 0 and longitude 0. Most wrappers should probably be moved to `api-common` in the future. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 3e4b56b commit 10bd74c

File tree

10 files changed

+579
-1
lines changed

10 files changed

+579
-1
lines changed

src/frequenz/client/microgrid/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99

1010
from ._client import MicrogridApiClient
11+
from ._delivery_area import DeliveryArea, EnergyMarketCodeType
1112
from ._exception import (
1213
ApiClientError,
1314
ClientNotConnected,
@@ -30,17 +31,27 @@
3031
UnknownError,
3132
UnrecognizedGrpcStatus,
3233
)
34+
from ._id import EnterpriseId, MicrogridId
35+
from ._location import Location
36+
from ._microgrid_info import MicrogridInfo, MicrogridStatus
3337

3438
__all__ = [
35-
"MicrogridApiClient",
3639
"ApiClientError",
3740
"ClientNotConnected",
3841
"DataLoss",
42+
"DeliveryArea",
43+
"EnergyMarketCodeType",
44+
"EnterpriseId",
3945
"EntityAlreadyExists",
4046
"EntityNotFound",
4147
"GrpcError",
4248
"InternalError",
4349
"InvalidArgument",
50+
"Location",
51+
"MicrogridApiClient",
52+
"MicrogridId",
53+
"MicrogridInfo",
54+
"MicrogridStatus",
4455
"OperationAborted",
4556
"OperationCancelled",
4657
"OperationNotImplemented",

src/frequenz/client/microgrid/_client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
from frequenz.api.microgrid.v1 import microgrid_pb2_grpc
1010
from frequenz.client.base import channel, client, retry, streaming
11+
from google.protobuf.empty_pb2 import Empty
12+
13+
from ._microgrid_info import MicrogridInfo
14+
from ._microgrid_info_proto import microgrid_info_from_proto
1115

1216
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
1317
"""The default timeout for gRPC calls made by this client (in seconds)."""
@@ -62,3 +66,30 @@ def __init__(
6266
self._async_stub: microgrid_pb2_grpc.MicrogridAsyncStub = self.stub # type: ignore
6367
self._broadcasters: dict[int, streaming.GrpcStreamBroadcaster[Any, Any]] = {}
6468
self._retry_strategy = retry_strategy
69+
70+
async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly)
71+
self,
72+
) -> MicrogridInfo:
73+
"""Fetch the information about the local microgrid.
74+
75+
This consists of information that describes the overall microgrid, as opposed to
76+
its electrical components or sensors, e.g., the microgrid ID, location.
77+
78+
Returns:
79+
The information about the local microgrid.
80+
81+
Raises:
82+
ApiClientError: If the are any errors communicating with the Microgrid API,
83+
most likely a subclass of
84+
[GrpcError][frequenz.client.microgrid.GrpcError].
85+
"""
86+
microgrid = await client.call_stub_method(
87+
self,
88+
lambda: self._async_stub.GetMicrogridMetadata(
89+
Empty(),
90+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
91+
),
92+
method_name="GetMicrogridMetadata",
93+
)
94+
95+
return microgrid_info_from_proto(microgrid.microgrid)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# License: MIT
2+
# Copyright © 2024 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+
10+
@enum.unique
11+
class EnergyMarketCodeType(enum.Enum):
12+
"""The identification code types used in the energy market.
13+
14+
CodeType specifies the type of identification code used for uniquely
15+
identifying various entities such as delivery areas, market participants,
16+
and grid components within the energy market.
17+
18+
This enumeration aims to
19+
offer compatibility across different jurisdictional standards.
20+
21+
Note: Understanding Code Types
22+
Different regions or countries may have their own standards for uniquely
23+
identifying various entities within the energy market. For example, in
24+
Europe, the Energy Identification Code (EIC) is commonly used for this
25+
purpose.
26+
27+
Note: Extensibility
28+
New code types can be added to this enum to accommodate additional regional
29+
standards, enhancing the API's adaptability.
30+
31+
Danger: Validation Required
32+
The chosen code type should correspond correctly with the `code` field in
33+
the relevant message objects, such as `DeliveryArea` or `Counterparty`.
34+
Failure to match the code type with the correct code could lead to
35+
processing errors.
36+
"""
37+
38+
UNSPECIFIED = 0
39+
"""Unspecified type. This value is a placeholder and should not be used."""
40+
41+
EUROPE_EIC = 1
42+
"""European Energy Identification Code Standard."""
43+
44+
US_NERC = 2
45+
"""North American Electric Reliability Corporation identifiers."""
46+
47+
48+
@dataclass(frozen=True, kw_only=True)
49+
class DeliveryArea:
50+
"""A geographical or administrative region where electricity deliveries occur.
51+
52+
DeliveryArea represents the geographical or administrative region, usually defined
53+
and maintained by a Transmission System Operator (TSO), where electricity deliveries
54+
for a contract occur.
55+
56+
The concept is important to energy trading as it delineates the agreed-upon delivery
57+
location. Delivery areas can have different codes based on the jurisdiction in
58+
which they operate.
59+
60+
Note: Jurisdictional Differences
61+
This is typically represented by specific codes according to local jurisdiction.
62+
63+
In Europe, this is represented by an
64+
[EIC](https://en.wikipedia.org/wiki/Energy_Identification_Code) (Energy
65+
Identification Code). [List of
66+
EICs](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/).
67+
"""
68+
69+
code: str | None
70+
"""The code representing the unique identifier for the delivery area."""
71+
72+
code_type: EnergyMarketCodeType | int
73+
"""Type of code used for identifying the delivery area itself.
74+
75+
This code could be extended in the future, in case an unknown code type is
76+
encountered, a plain integer value is used to represent it.
77+
"""
78+
79+
def __str__(self) -> str:
80+
"""Return a human-readable string representation of this instance."""
81+
code = self.code or "<NO CODE>"
82+
code_type = (
83+
f"type={self.code_type}"
84+
if isinstance(self.code_type, int)
85+
else self.code_type.name
86+
)
87+
return f"{code}[{code_type}]"
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# License: MIT
2+
# Copyright © 2024 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.v1.grid import delivery_area_pb2
9+
10+
from ._delivery_area import DeliveryArea, EnergyMarketCodeType
11+
from ._util import enum_from_proto
12+
13+
_logger = logging.getLogger(__name__)
14+
15+
16+
def delivery_area_from_proto(
17+
message: delivery_area_pb2.DeliveryArea,
18+
) -> DeliveryArea:
19+
"""Convert a protobuf delivery area message to a delivery area object.
20+
21+
Args:
22+
message: The protobuf message to convert.
23+
24+
Returns:
25+
The resulting delivery area object.
26+
"""
27+
issues: list[str] = []
28+
29+
code = message.code or None
30+
if code is None:
31+
issues.append("code is empty")
32+
33+
code_type = enum_from_proto(message.code_type, EnergyMarketCodeType)
34+
if code_type is EnergyMarketCodeType.UNSPECIFIED:
35+
issues.append("code_type is unspecified")
36+
elif isinstance(code_type, int):
37+
issues.append("code_type is unrecognized")
38+
39+
if issues:
40+
_logger.warning(
41+
"Found issues in delivery area: %s | Protobuf message:\n%s",
42+
", ".join(issues),
43+
message,
44+
)
45+
46+
return DeliveryArea(code=code, code_type=code_type)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Metadata that describes a microgrid."""
5+
6+
7+
class EnterpriseId:
8+
"""A unique identifier for an enterprise account."""
9+
10+
def __init__(self, id_: int, /) -> None:
11+
"""Initialize this instance.
12+
13+
Args:
14+
id_: The numeric unique identifier of the enterprise account.
15+
16+
Raises:
17+
ValueError: If the ID is negative.
18+
"""
19+
if id_ < 0:
20+
raise ValueError("Enterprise ID can't be negative.")
21+
self._id = id_
22+
23+
def __int__(self) -> int:
24+
"""Return the numeric ID of this instance."""
25+
return self._id
26+
27+
def __eq__(self, other: object) -> bool:
28+
"""Check if this instance is equal to another object."""
29+
# This is not an unidiomatic typecheck, that's an odd name for the check.
30+
# isinstance() returns True for subclasses, which is not what we want here.
31+
# pylint: disable-next=unidiomatic-typecheck
32+
return type(other) is EnterpriseId and self._id == other._id
33+
34+
def __hash__(self) -> int:
35+
"""Return the hash of this instance."""
36+
# We include the class because we explicitly want to avoid the same ID to give
37+
# the same hash for different classes of IDs
38+
return hash((EnterpriseId, self._id))
39+
40+
def __repr__(self) -> str:
41+
"""Return the string representation of this instance."""
42+
return f"{type(self).__name__}({self._id!r})"
43+
44+
def __str__(self) -> str:
45+
"""Return the short string representation of this instance."""
46+
return f"EID{self._id}"
47+
48+
49+
class MicrogridId:
50+
"""A unique identifier for a microgrid."""
51+
52+
def __init__(self, id_: int, /) -> None:
53+
"""Initialize this instance.
54+
55+
Args:
56+
id_: The numeric unique identifier of the microgrid.
57+
58+
Raises:
59+
ValueError: If the ID is negative.
60+
"""
61+
if id_ < 0:
62+
raise ValueError("Microgrid ID can't be negative.")
63+
self._id = id_
64+
65+
def __int__(self) -> int:
66+
"""Return the numeric ID of this instance."""
67+
return self._id
68+
69+
def __eq__(self, other: object) -> bool:
70+
"""Check if this instance is equal to another object."""
71+
# This is not an unidiomatic typecheck, that's an odd name for the check.
72+
# isinstance() returns True for subclasses, which is not what we want here.
73+
# pylint: disable-next=unidiomatic-typecheck
74+
return type(other) is MicrogridId and self._id == other._id
75+
76+
def __hash__(self) -> int:
77+
"""Return the hash of this instance."""
78+
# We include the class because we explicitly want to avoid the same ID to give
79+
# the same hash for different classes of IDs
80+
return hash((MicrogridId, self._id))
81+
82+
def __repr__(self) -> str:
83+
"""Return the string representation of this instance."""
84+
return f"{type(self).__name__}({self._id!r})"
85+
86+
def __str__(self) -> str:
87+
"""Return the short string representation of this instance."""
88+
return f"MID{self._id}"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Location information for a microgrid."""
5+
6+
7+
import logging
8+
from dataclasses import dataclass
9+
from functools import cached_property
10+
from zoneinfo import ZoneInfo
11+
12+
import timezonefinder
13+
14+
_timezone_finder = timezonefinder.TimezoneFinder()
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
@dataclass(frozen=True, kw_only=True)
19+
class Location:
20+
"""A location of a microgrid."""
21+
22+
latitude: float | None
23+
"""The latitude of the microgrid in degree."""
24+
25+
longitude: float | None
26+
"""The longitude of the microgrid in degree."""
27+
28+
country_code: str | None
29+
"""The country code of the microgrid in ISO 3166-1 Alpha 2 format."""
30+
31+
@cached_property
32+
def timezone(self) -> ZoneInfo | None:
33+
"""The timezone of the microgrid, or `None` if it could not be determined."""
34+
if self.latitude is None or self.longitude is None:
35+
_logger.warning(
36+
"Latitude (%s) or longitude (%s) missing, cannot determine timezone"
37+
)
38+
return None
39+
timezone = _timezone_finder.timezone_at(lat=self.latitude, lng=self.longitude)
40+
return ZoneInfo(key=timezone) if timezone else None
41+
42+
def __str__(self) -> str:
43+
"""Return the short string representation of this instance."""
44+
country = self.country_code or "<NO COUNTRY CODE>"
45+
lat = f"{self.latitude:.2f}" if self.latitude is not None else "?"
46+
lon = f"{self.longitude:.2f}" if self.longitude is not None else "?"
47+
coordinates = ""
48+
if self.latitude is not None or self.longitude is not None:
49+
coordinates = f":({lat}, {lon})"
50+
return f"{country}{coordinates}"

0 commit comments

Comments
 (0)