Skip to content

Commit 8e23009

Browse files
committed
Add Location
This will be used to retrieve the microgrid information. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent f0f2765 commit 8e23009

File tree

4 files changed

+274
-0
lines changed

4 files changed

+274
-0
lines changed

src/frequenz/client/microgrid/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
UnrecognizedGrpcStatus,
3333
)
3434
from ._lifetime import Lifetime
35+
from ._location import Location
3536

3637
__all__ = [
3738
"ApiClientError",
@@ -45,6 +46,7 @@
4546
"InternalError",
4647
"InvalidArgument",
4748
"Lifetime",
49+
"Location",
4850
"MicrogridApiClient",
4951
"OperationAborted",
5052
"OperationCancelled",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# License: MIT
2+
# Copyright © 2025 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}"
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.v1 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)

tests/test_location.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the microgrid metadata types."""
5+
6+
from collections.abc import Iterator
7+
from dataclasses import dataclass
8+
from unittest.mock import MagicMock, patch
9+
from zoneinfo import ZoneInfo
10+
11+
import pytest
12+
from frequenz.api.common.v1 import location_pb2
13+
14+
from frequenz.client.microgrid import Location
15+
from frequenz.client.microgrid._location_proto import location_from_proto
16+
17+
18+
@dataclass(frozen=True, kw_only=True)
19+
class _ProtoConversionTestCase: # pylint: disable=too-many-instance-attributes
20+
"""Test case for protobuf conversion."""
21+
22+
name: str
23+
"""The description of the test case."""
24+
25+
latitude: float
26+
"""The latitude to set in the protobuf message."""
27+
28+
longitude: float
29+
"""The longitude to set in the protobuf message."""
30+
31+
country_code: str
32+
"""The country code to set in the protobuf message."""
33+
34+
expected_none_latitude: bool = False
35+
"""The latitude is expected to be None."""
36+
37+
expected_none_longitude: bool = False
38+
"""The longitude is expected to be None."""
39+
40+
expected_none_country_code: bool = False
41+
"""The country code is expected to be None."""
42+
43+
expect_warning: bool = False
44+
"""Whether to expect a warning during conversion."""
45+
46+
47+
@pytest.fixture
48+
def timezone_finder() -> Iterator[MagicMock]:
49+
"""Return a mock timezone finder."""
50+
with patch(
51+
"frequenz.client.microgrid._location._timezone_finder", autospec=True
52+
) as mock_timezone_finder:
53+
yield mock_timezone_finder
54+
55+
56+
def test_timezone_not_looked_up_if_unused(timezone_finder: MagicMock) -> None:
57+
"""Test the location timezone is not looked up if it is not used."""
58+
location = Location(latitude=52.52, longitude=13.405, country_code="DE")
59+
60+
assert location.latitude == 52.52
61+
assert location.longitude == 13.405
62+
assert location.country_code == "DE"
63+
timezone_finder.timezone_at.assert_not_called()
64+
65+
66+
def test_timezone_looked_up_but_not_found(timezone_finder: MagicMock) -> None:
67+
"""Test the location timezone is not looked up if it is not used."""
68+
timezone_finder.timezone_at.return_value = None
69+
70+
location = Location(latitude=52.52, longitude=13.405, country_code="DE")
71+
72+
assert location.timezone is None
73+
timezone_finder.timezone_at.assert_called_once_with(lat=52.52, lng=13.405)
74+
75+
76+
def test_timezone_looked_up_and_found(timezone_finder: MagicMock) -> None:
77+
"""Test the location timezone is not looked up if it is not used."""
78+
timezone_finder.timezone_at.return_value = "Europe/Berlin"
79+
80+
location = Location(latitude=52.52, longitude=13.405, country_code="DE")
81+
82+
assert location.timezone == ZoneInfo(key="Europe/Berlin")
83+
timezone_finder.timezone_at.assert_called_once_with(lat=52.52, lng=13.405)
84+
85+
86+
@pytest.mark.parametrize(
87+
"case",
88+
[
89+
_ProtoConversionTestCase(
90+
name="valid",
91+
latitude=52.52,
92+
longitude=13.405,
93+
country_code="DE",
94+
),
95+
_ProtoConversionTestCase(
96+
name="boundary_latitude",
97+
latitude=90.0,
98+
longitude=13.405,
99+
country_code="DE",
100+
),
101+
_ProtoConversionTestCase(
102+
name="boundary_longitude",
103+
latitude=52.52,
104+
longitude=180.0,
105+
country_code="DE",
106+
),
107+
_ProtoConversionTestCase(
108+
name="invalid_latitude",
109+
latitude=91.0,
110+
longitude=13.405,
111+
country_code="DE",
112+
expected_none_latitude=True,
113+
expect_warning=True,
114+
),
115+
_ProtoConversionTestCase(
116+
name="invalid_longitude",
117+
latitude=52.52,
118+
longitude=181.0,
119+
country_code="DE",
120+
expected_none_longitude=True,
121+
expect_warning=True,
122+
),
123+
_ProtoConversionTestCase(
124+
name="empty_country_code",
125+
latitude=52.52,
126+
longitude=13.405,
127+
country_code="",
128+
expected_none_country_code=True,
129+
expect_warning=True,
130+
),
131+
_ProtoConversionTestCase(
132+
name="all_invalid",
133+
latitude=-91.0,
134+
longitude=181.0,
135+
country_code="",
136+
expected_none_latitude=True,
137+
expected_none_longitude=True,
138+
expected_none_country_code=True,
139+
expect_warning=True,
140+
),
141+
],
142+
ids=lambda case: case.name,
143+
)
144+
def test_from_proto(
145+
caplog: pytest.LogCaptureFixture, case: _ProtoConversionTestCase
146+
) -> None:
147+
"""Test conversion from protobuf message to Location."""
148+
proto = location_pb2.Location(
149+
latitude=case.latitude,
150+
longitude=case.longitude,
151+
country_code=case.country_code,
152+
)
153+
with caplog.at_level("WARNING"):
154+
location = location_from_proto(proto)
155+
156+
if case.expected_none_latitude:
157+
assert location.latitude is None
158+
else:
159+
assert location.latitude == pytest.approx(case.latitude)
160+
161+
if case.expected_none_longitude:
162+
assert location.longitude is None
163+
else:
164+
assert location.longitude == pytest.approx(case.longitude)
165+
166+
if case.expected_none_country_code:
167+
assert location.country_code is None
168+
else:
169+
assert location.country_code == case.country_code
170+
171+
if case.expect_warning:
172+
assert len(caplog.records) > 0
173+
assert "Found issues in location:" in caplog.records[0].message
174+
else:
175+
assert len(caplog.records) == 0

0 commit comments

Comments
 (0)