Skip to content

Commit 28d25a8

Browse files
authored
Fix bug in GridFrequency quantity conversion (#807)
This is a follow-up to #804. The sample value can also be `None`, in which case we can't access to `sample.value.base_value`. In that case we just cast the existing sample as it doesn't matter which type the value has when it is `None`, avoiding the extra copying. I discovered this bug while implementing a solution for #806. Which kind of proves it is a worthy pursue... Also fixes #831.
2 parents 5c9a1c5 + fe96a3d commit 28d25a8

File tree

5 files changed

+70
-52
lines changed

5 files changed

+70
-52
lines changed

RELEASE_NOTES.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,13 @@
5757
## Bug Fixes
5858

5959
- 0W power requests are now not adjusted to exclusion bounds by the `PowerManager` and `PowerDistributor`, and are sent over to the microgrid API directly.
60-
- Fixed that `microgrid.frequency()` was sending `Quantity` objects instead of `Frequency`.
60+
61+
- `microgrid.frequency()` / `GridFrequency`:
62+
63+
* Fix sent samples to use `Frequency` objects instead of raw `Quantity`.
64+
* Handle `None` values in the received samples properly.
65+
* Convert `nan` values in the received samples to `None`.
66+
6167
- The resampler now properly handles sending zero values.
6268

6369
A bug made the resampler interpret zero values as `None` when generating new samples, so if the result of the resampling is zero, the resampler would just produce `None` values.

src/frequenz/sdk/timeseries/_grid_frequency.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,9 @@ def new_receiver(self) -> Receiver[Sample[Frequency]]:
107107
)
108108

109109
return receiver.map(
110-
lambda sample: Sample(
111-
sample.timestamp, Frequency.from_hertz(sample.value.base_value)
112-
)
110+
lambda sample: Sample[Frequency](sample.timestamp, None)
111+
if sample.value is None or sample.value.isnan()
112+
else Sample(sample.timestamp, Frequency.from_hertz(sample.value.base_value))
113113
)
114114

115115
async def _send_request(self) -> None:

tests/timeseries/mock_resampler.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66

77
import asyncio
8+
import math
89
from datetime import datetime
910

1011
from frequenz.channels import Broadcast, Receiver, Sender
@@ -187,58 +188,65 @@ async def _handle_resampling_requests(self) -> None:
187188
)
188189
)
189190

191+
def make_sample(self, value: float | None) -> Sample[Quantity]:
192+
"""Create a sample with the given value."""
193+
return Sample(
194+
self._next_ts,
195+
None if value is None or math.isnan(value) else Quantity(value),
196+
)
197+
190198
async def send_meter_power(self, values: list[float | None]) -> None:
191199
"""Send the given values as resampler output for meter power."""
192200
assert len(values) == len(self._meter_power_senders)
193201
for chan, value in zip(self._meter_power_senders, values):
194-
sample = Sample(self._next_ts, None if not value else Quantity(value))
202+
sample = self.make_sample(value)
195203
await chan.send(sample)
196204

197205
async def send_chp_power(self, values: list[float | None]) -> None:
198206
"""Send the given values as resampler output for CHP power."""
199207
assert len(values) == len(self._chp_power_senders)
200208
for chan, value in zip(self._chp_power_senders, values):
201-
sample = Sample(self._next_ts, None if not value else Quantity(value))
209+
sample = self.make_sample(value)
202210
await chan.send(sample)
203211

204212
async def send_pv_inverter_power(self, values: list[float | None]) -> None:
205213
"""Send the given values as resampler output for PV Inverter power."""
206214
assert len(values) == len(self._pv_inverter_power_senders)
207215
for chan, value in zip(self._pv_inverter_power_senders, values):
208-
sample = Sample(self._next_ts, None if not value else Quantity(value))
216+
sample = self.make_sample(value)
209217
await chan.send(sample)
210218

211219
async def send_meter_frequency(self, values: list[float | None]) -> None:
212220
"""Send the given values as resampler output for meter frequency."""
213221
assert len(values) == len(self._meter_frequency_senders)
214222
for sender, value in zip(self._meter_frequency_senders, values):
215-
sample = Sample(self._next_ts, None if not value else Quantity(value))
223+
sample = self.make_sample(value)
216224
await sender.send(sample)
217225

218226
async def send_bat_inverter_frequency(self, values: list[float | None]) -> None:
219227
"""Send the given values as resampler output for battery inverter frequency."""
220228
assert len(values) == len(self._bat_inverter_frequency_senders)
221229
for chan, value in zip(self._bat_inverter_frequency_senders, values):
222-
sample = Sample(self._next_ts, None if not value else Quantity(value))
230+
sample = self.make_sample(value)
223231
await chan.send(sample)
224232

225233
async def send_evc_power(self, values: list[float | None]) -> None:
226234
"""Send the given values as resampler output for EV Charger power."""
227235
assert len(values) == len(self._ev_power_senders)
228236
for chan, value in zip(self._ev_power_senders, values):
229-
sample = Sample(self._next_ts, None if not value else Quantity(value))
237+
sample = self.make_sample(value)
230238
await chan.send(sample)
231239

232240
async def send_bat_inverter_power(self, values: list[float | None]) -> None:
233241
"""Send the given values as resampler output for battery inverter power."""
234242
assert len(values) == len(self._bat_inverter_power_senders)
235243
for chan, value in zip(self._bat_inverter_power_senders, values):
236-
sample = Sample(self._next_ts, None if not value else Quantity(value))
244+
sample = self.make_sample(value)
237245
await chan.send(sample)
238246

239247
async def send_non_existing_component_value(self) -> None:
240248
"""Send a value for a non existing component."""
241-
sample = Sample[Quantity](self._next_ts, None)
249+
sample = self.make_sample(None)
242250
await self._non_existing_component_sender.send(sample)
243251

244252
async def send_evc_current(self, values: list[list[float | None]]) -> None:
@@ -247,7 +255,7 @@ async def send_evc_current(self, values: list[list[float | None]]) -> None:
247255
for chan, evc_values in zip(self._ev_current_senders, values):
248256
assert len(evc_values) == 3 # 3 values for phases
249257
for phase, value in enumerate(evc_values):
250-
sample = Sample(self._next_ts, None if not value else Quantity(value))
258+
sample = self.make_sample(value)
251259
await chan[phase].send(sample)
252260

253261
async def send_bat_inverter_current(self, values: list[list[float | None]]) -> None:
@@ -256,7 +264,7 @@ async def send_bat_inverter_current(self, values: list[list[float | None]]) -> N
256264
for chan, bat_values in zip(self._bat_inverter_current_senders, values):
257265
assert len(bat_values) == 3 # 3 values for phases
258266
for phase, value in enumerate(bat_values):
259-
sample = Sample(self._next_ts, None if not value else Quantity(value))
267+
sample = self.make_sample(value)
260268
await chan[phase].send(sample)
261269

262270
async def send_meter_current(self, values: list[list[float | None]]) -> None:
@@ -265,7 +273,7 @@ async def send_meter_current(self, values: list[list[float | None]]) -> None:
265273
for chan, meter_values in zip(self._meter_current_senders, values):
266274
assert len(meter_values) == 3 # 3 values for phases
267275
for phase, value in enumerate(meter_values):
268-
sample = Sample(self._next_ts, None if not value else Quantity(value))
276+
sample = self.make_sample(value)
269277
await chan[phase].send(sample)
270278

271279
async def send_meter_voltage(self, values: list[list[float | None]]) -> None:
@@ -274,5 +282,5 @@ async def send_meter_voltage(self, values: list[list[float | None]]) -> None:
274282
for chan, meter_values in zip(self._meter_voltage_senders, values):
275283
assert len(meter_values) == 3 # 3 values for phases
276284
for phase, value in enumerate(meter_values):
277-
sample = Sample(self._next_ts, None if not value else Quantity(value))
285+
sample = self.make_sample(value)
278286
await chan[phase].send(sample)

tests/timeseries/test_frequency_streaming.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,34 @@
1919
# pylint: disable=protected-access
2020

2121

22+
async def test_grid_frequency_none(mocker: MockerFixture) -> None:
23+
"""Test the grid frequency formula."""
24+
mockgrid = MockMicrogrid(grid_meter=True)
25+
mockgrid.add_batteries(2)
26+
mockgrid.add_solar_inverters(1)
27+
await mockgrid.start(mocker)
28+
29+
grid_freq = microgrid.frequency()
30+
grid_freq_recv = grid_freq.new_receiver()
31+
32+
assert grid_freq._task is not None
33+
# We have to wait for the metric request to be sent
34+
await grid_freq._task
35+
# And consumed
36+
await asyncio.sleep(0)
37+
38+
await mockgrid.mock_client.send(
39+
component_data_wrapper.MeterDataWrapper(
40+
mockgrid.meter_ids[0], datetime.now(tz=timezone.utc)
41+
)
42+
)
43+
44+
val = await grid_freq_recv.receive()
45+
assert val is not None
46+
assert val.value is None
47+
await mockgrid.cleanup()
48+
49+
2250
async def test_grid_frequency_1(mocker: MockerFixture) -> None:
2351
"""Test the grid frequency formula."""
2452
mockgrid = MockMicrogrid(grid_meter=True)

tests/utils/component_data_wrapper.py

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ def __init__( # pylint: disable=too-many-arguments
101101
component_id: int,
102102
timestamp: datetime,
103103
active_power: float = math.nan,
104-
current_per_phase: tuple[float, float, float] | None = None,
105-
voltage_per_phase: tuple[float, float, float] | None = None,
104+
current_per_phase: tuple[float, float, float] = (math.nan, math.nan, math.nan),
105+
voltage_per_phase: tuple[float, float, float] = (math.nan, math.nan, math.nan),
106106
active_power_inclusion_lower_bound: float = math.nan,
107107
active_power_exclusion_lower_bound: float = math.nan,
108108
active_power_inclusion_upper_bound: float = math.nan,
@@ -122,16 +122,8 @@ def __init__( # pylint: disable=too-many-arguments
122122
component_id=component_id,
123123
timestamp=timestamp,
124124
active_power=active_power,
125-
current_per_phase=(
126-
current_per_phase
127-
if current_per_phase
128-
else (math.nan, math.nan, math.nan)
129-
),
130-
voltage_per_phase=(
131-
voltage_per_phase
132-
if voltage_per_phase
133-
else (math.nan, math.nan, math.nan)
134-
),
125+
current_per_phase=current_per_phase,
126+
voltage_per_phase=voltage_per_phase,
135127
active_power_inclusion_lower_bound=active_power_inclusion_lower_bound,
136128
active_power_exclusion_lower_bound=active_power_exclusion_lower_bound,
137129
active_power_inclusion_upper_bound=active_power_inclusion_upper_bound,
@@ -165,8 +157,8 @@ def __init__( # pylint: disable=too-many-arguments
165157
component_id: int,
166158
timestamp: datetime,
167159
active_power: float = math.nan,
168-
current_per_phase: tuple[float, float, float] | None = None,
169-
voltage_per_phase: tuple[float, float, float] | None = None,
160+
current_per_phase: tuple[float, float, float] = (math.nan, math.nan, math.nan),
161+
voltage_per_phase: tuple[float, float, float] = (math.nan, math.nan, math.nan),
170162
frequency: float = 50.0,
171163
cable_state: EVChargerCableState = EVChargerCableState.UNSPECIFIED,
172164
component_state: EVChargerComponentState = EVChargerComponentState.UNSPECIFIED,
@@ -180,16 +172,8 @@ def __init__( # pylint: disable=too-many-arguments
180172
component_id=component_id,
181173
timestamp=timestamp,
182174
active_power=active_power,
183-
current_per_phase=(
184-
current_per_phase
185-
if current_per_phase
186-
else (math.nan, math.nan, math.nan)
187-
),
188-
voltage_per_phase=(
189-
voltage_per_phase
190-
if voltage_per_phase
191-
else (math.nan, math.nan, math.nan)
192-
),
175+
current_per_phase=current_per_phase,
176+
voltage_per_phase=voltage_per_phase,
193177
frequency=frequency,
194178
cable_state=cable_state,
195179
component_state=component_state,
@@ -219,8 +203,8 @@ def __init__( # pylint: disable=too-many-arguments
219203
component_id: int,
220204
timestamp: datetime,
221205
active_power: float = math.nan,
222-
current_per_phase: tuple[float, float, float] | None = None,
223-
voltage_per_phase: tuple[float, float, float] | None = None,
206+
current_per_phase: tuple[float, float, float] = (math.nan, math.nan, math.nan),
207+
voltage_per_phase: tuple[float, float, float] = (math.nan, math.nan, math.nan),
224208
frequency: float = math.nan,
225209
) -> None:
226210
"""Initialize the MeterDataWrapper.
@@ -232,16 +216,8 @@ def __init__( # pylint: disable=too-many-arguments
232216
component_id=component_id,
233217
timestamp=timestamp,
234218
active_power=active_power,
235-
current_per_phase=(
236-
current_per_phase
237-
if current_per_phase
238-
else (math.nan, math.nan, math.nan)
239-
),
240-
voltage_per_phase=(
241-
voltage_per_phase
242-
if voltage_per_phase
243-
else (math.nan, math.nan, math.nan)
244-
),
219+
current_per_phase=current_per_phase,
220+
voltage_per_phase=voltage_per_phase,
245221
frequency=frequency,
246222
)
247223

0 commit comments

Comments
 (0)