Skip to content

Commit e0683d4

Browse files
authored
Feat/clean up duplicate flattening implementation (#1984)
* refactor: move extract_sensors_from_flex_config to schemas/generic_assets.py Signed-off-by: F.N. Claessen <claessen@seita.nl> * delete: internal import no longer needed Signed-off-by: F.N. Claessen <claessen@seita.nl> * refactor: rename util method to flatten_sensors_to_show Signed-off-by: F.N. Claessen <claessen@seita.nl> * refactor: get rid of duplicate implementation for flattening sensors to show Signed-off-by: F.N. Claessen <claessen@seita.nl> * feat: make doctest out of example Signed-off-by: F.N. Claessen <claessen@seita.nl> * style: rst-style docstrings Signed-off-by: F.N. Claessen <claessen@seita.nl> * feat: support multiple flex-config field names Signed-off-by: F.N. Claessen <claessen@seita.nl> * feat: test flatten function Signed-off-by: F.N. Claessen <claessen@seita.nl> --------- Signed-off-by: F.N. Claessen <claessen@seita.nl>
1 parent af3f3ca commit e0683d4

File tree

8 files changed

+140
-112
lines changed

8 files changed

+140
-112
lines changed

flexmeasures/api/v3_0/assets.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
GenericAssetSchema as AssetSchema,
4848
GenericAssetIdField as AssetIdField,
4949
GenericAssetTypeSchema as AssetTypeSchema,
50+
SensorsToShowSchema,
5051
)
5152
from flexmeasures.data.schemas.scheduling.storage import StorageFlexModelSchema
5253
from flexmeasures.data.schemas.scheduling import AssetTriggerSchema, FlexContextSchema
@@ -61,9 +62,6 @@
6162
)
6263
from flexmeasures.api.common.schemas.users import AccountIdField
6364
from flexmeasures.api.common.schemas.assets import default_response_fields
64-
from flexmeasures.utils.coding_utils import (
65-
flatten_unique,
66-
)
6765
from flexmeasures.ui.utils.view_utils import clear_session, set_session_variables
6866
from flexmeasures.auth.policy import check_access
6967
from flexmeasures.data.schemas.sensors import (
@@ -896,7 +894,7 @@ def get_chart_data(self, id: int, asset: GenericAsset, **kwargs):
896894
tags:
897895
- Assets
898896
"""
899-
sensors = flatten_unique(asset.validate_sensors_to_show())
897+
sensors = SensorsToShowSchema.flatten(asset.validate_sensors_to_show())
900898
return asset.search_beliefs(sensors=sensors, as_json=True, **kwargs)
901899

902900
@route("/<id>/auditlog")

flexmeasures/conftest.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,31 @@ def create_assets(
548548
),
549549
)
550550
db.session.add(sensor)
551+
scc_sensor = Sensor(
552+
name="site-consumption-capacity",
553+
generic_asset=asset,
554+
event_resolution=timedelta(minutes=15),
555+
unit="MW",
556+
)
557+
db.session.add(scc_sensor)
558+
cc_sensor = Sensor(
559+
name="consumption-capacity",
560+
generic_asset=asset,
561+
event_resolution=timedelta(minutes=15),
562+
unit="MW",
563+
)
564+
db.session.add(cc_sensor)
565+
pc_sensor = Sensor(
566+
name="production-capacity",
567+
generic_asset=asset,
568+
event_resolution=timedelta(minutes=15),
569+
unit="MW",
570+
)
571+
db.session.add(pc_sensor)
572+
db.session.flush() # assign sensor IDs
573+
asset.flex_model["consumption-capacity"] = {"sensor": cc_sensor.id}
574+
asset.flex_model["production-capacity"] = {"sensor": pc_sensor.id}
575+
asset.flex_context["site-consumption-capacity"] = {"sensor": scc_sensor.id}
551576
assets.append(asset)
552577

553578
# one day of test data (one complete sine curve)

flexmeasures/data/models/charts/belief_charts.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from flexmeasures.utils.flexmeasures_inflection import (
1212
capitalize,
1313
)
14-
from flexmeasures.utils.coding_utils import flatten_unique
1514
from flexmeasures.utils.unit_utils import find_smallest_common_unit, get_unit_dimension
1615

1716

@@ -499,8 +498,10 @@ def chart_for_multiple_sensors(
499498
combine_legend: bool = True,
500499
**override_chart_specs: dict,
501500
):
501+
from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema
502+
502503
# Determine the shared data resolution
503-
all_shown_sensors = flatten_unique(sensors_to_show)
504+
all_shown_sensors = SensorsToShowSchema.flatten(sensors_to_show)
504505
condition = list(
505506
sensor.event_resolution
506507
for sensor in all_shown_sensors

flexmeasures/data/models/generic_assets.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
CONSULTANT_ROLE,
3131
)
3232
from flexmeasures.utils import geo_utils
33-
from flexmeasures.utils.coding_utils import flatten_unique
3433
from flexmeasures.utils.time_utils import determine_minimum_resampling_resolution
3534
from flexmeasures.utils.unit_utils import find_smallest_common_unit
3635

@@ -287,7 +286,9 @@ def validate_sensors_to_show(
287286

288287
sensor_ids_to_show = self.sensors_to_show
289288
# Import the schema for validation
290-
from flexmeasures.data.schemas.utils import extract_sensors_from_flex_config
289+
from flexmeasures.data.schemas.generic_assets import (
290+
extract_sensors_from_flex_config,
291+
)
291292
from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema
292293

293294
sensors_to_show_schema = SensorsToShowSchema()
@@ -662,8 +663,10 @@ def chart(
662663
:param resolution: optionally set the resolution of data being displayed
663664
:returns: JSON string defining vega-lite chart specs
664665
"""
666+
from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema
667+
665668
processed_sensors_to_show = self.validate_sensors_to_show()
666-
sensors = flatten_unique(processed_sensors_to_show)
669+
sensors = SensorsToShowSchema.flatten(processed_sensors_to_show)
667670

668671
for sensor in sensors:
669672
sensor.sensor_type = sensor.get_attribute("sensor_type", sensor.name)
@@ -1023,7 +1026,9 @@ def get_timerange(cls, sensors: list["Sensor"]) -> dict[str, datetime]: # noqa
10231026
'end': datetime.datetime(2020, 12, 3, 14, 30, tzinfo=pytz.utc)
10241027
}
10251028
"""
1026-
sensor_ids = [s.id for s in flatten_unique(sensors)]
1029+
from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema
1030+
1031+
sensor_ids = [s.id for s in SensorsToShowSchema.flatten(sensors)]
10271032
start, end = get_timerange(sensor_ids)
10281033
return dict(start=start, end=end)
10291034

flexmeasures/data/schemas/generic_assets.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
from datetime import timedelta
44
import json
55
from http import HTTPStatus
6-
from typing import Any
6+
from typing import Any, TYPE_CHECKING
77

88
from flask import abort
99
from marshmallow import validates, ValidationError, fields, validates_schema
1010
from marshmallow.validate import OneOf
1111
from flask_security import current_user
1212
from sqlalchemy import select
1313

14-
14+
if TYPE_CHECKING:
15+
from flexmeasures import Sensor
1516
from flexmeasures.data import ma, db
1617
from flexmeasures.data.models.user import Account
1718
from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType
@@ -21,7 +22,6 @@
2122
from flexmeasures.data.schemas.utils import (
2223
FMValidationError,
2324
MarshmallowClickMixin,
24-
extract_sensors_from_flex_config,
2525
)
2626
from flexmeasures.auth.policy import user_has_admin_access
2727
from flexmeasures.cli import is_running as running_as_cli
@@ -211,23 +211,34 @@ def _validate_flex_config_field_is_valid_choice(
211211
)
212212

213213
@classmethod
214-
def flatten(cls, nested_list) -> list[int]:
214+
def flatten(cls, nested_list: list) -> list[int] | list[Sensor]:
215215
"""
216-
Flatten a nested list of sensors or sensor dictionaries into a unique list of sensor IDs.
217-
218-
This method processes the following formats, for each of the entries of the nested list:
219-
- A list of sensor IDs: `[1, 2, 3]`
220-
- A list of dictionaries where each dictionary contains a `sensors` list, a `sensor` key or a `plots` key
221-
`[{"title": "Temperature", "sensors": [1, 2]}, {"title": "Pressure", "sensor": 3}, {"title": "Pressure", "plots": [{"sensor": 4}, {"sensors": [5,6]}]}]`
222-
- Mixed formats: `[{"title": "Temperature", "sensors": [1, 2]}, {"title": "Pressure", "sensor": 3}, 4, 5, 1]`
223-
224-
It extracts all sensor IDs, removes duplicates, and returns a flattened list of unique sensor IDs.
225-
226-
Args:
227-
nested_list (list): A list containing sensor IDs, or dictionaries with `sensors` or `sensor` keys.
228-
229-
Returns:
230-
list: A unique list of sensor IDs.
216+
Flatten a nested list of sensor IDs into a unique list. Also works for Sensor objects.
217+
218+
This method processes the following formats for each entry in the list:
219+
1. A single sensor ID:
220+
`3`
221+
2. A list of sensor IDs:
222+
`[1, 2]`
223+
3. A dictionary with a `sensor` key:
224+
`{"sensor": 3}`
225+
4. A dictionary with a `sensors` key:
226+
`{"sensors": [1, 2]}`
227+
5. A dictionary with a `plots` key, containing a list of dictionaries,
228+
each with a `sensor` or `sensors` key:
229+
`{"plots": [{"sensor": 4}, {"sensors": [5, 6]}]}`
230+
6. A dictionary under the `plots` key containing the `asset` key together with a `flex-model` or `flex-context` key,
231+
containing a field name or a list of field names:
232+
`{"plots": [{"asset": 100, "flex-model": ["consumption-capacity", "production-capacity"], "flex-context": "site-power-capacity"}}`
233+
7. Mixed formats:
234+
`[{"title": "Temperature", "sensors": [1, 2]}, {"title": "Pressure", "sensor": 3}, {"title": "Pressure", "plots": [{"sensor": 4}, {"sensors": [5, 6]}]}]`
235+
236+
Example:
237+
>>> SensorsToShowSchema.flatten([1, [2, 20, 6], 10, [6, 2], {"title": None,"sensors": [10, 15]}, 15, {"plots": [{"sensor": 1}, {"sensors": [20, 8]}]}])
238+
[1, 2, 20, 6, 10, 15, 8]
239+
240+
:param nested_list: A list containing sensor IDs, or dictionaries with `sensors` or `sensor` keys.
241+
:returns: A unique list of sensor IDs, or a unique list of Sensors
231242
"""
232243
all_objects = []
233244
for s in nested_list:
@@ -249,7 +260,6 @@ def flatten(cls, nested_list) -> list[int]:
249260
all_objects.extend(s["sensors"])
250261
elif "sensor" in s:
251262
all_objects.append(s["sensor"])
252-
253263
return list(dict.fromkeys(all_objects).keys())
254264

255265

@@ -427,3 +437,41 @@ def _deserialize(self, value: Any, attr, data, **kwargs) -> GenericAsset:
427437
def _serialize(self, value: GenericAsset, attr, obj, **kwargs) -> int:
428438
"""Turn a GenericAsset into a generic asset id."""
429439
return value.id
440+
441+
442+
def extract_sensors_from_flex_config(plot: dict) -> list[Sensor]:
443+
"""
444+
Extracts a consolidated list of sensors from an asset based on
445+
flex-context or flex-model definitions provided in a plot dictionary.
446+
"""
447+
all_sensors = []
448+
449+
asset = GenericAssetIdField().deserialize(plot.get("asset"))
450+
451+
fields_to_check = {
452+
"flex-context": asset.flex_context,
453+
"flex-model": asset.flex_model,
454+
}
455+
456+
for plot_key, flex_config in fields_to_check.items():
457+
if plot_key not in plot:
458+
continue
459+
460+
field_keys = plot[plot_key]
461+
data = flex_config or {}
462+
463+
if isinstance(field_keys, str):
464+
field_keys = [field_keys]
465+
elif not isinstance(field_keys, list):
466+
continue
467+
468+
for field_key in field_keys:
469+
field_value = data.get(field_key)
470+
471+
if isinstance(field_value, dict):
472+
# Add a single sensor if it exists
473+
sensor = field_value.get("sensor")
474+
if sensor:
475+
all_sensors.append(sensor)
476+
477+
return all_sensors

flexmeasures/data/schemas/utils.py

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from pint import DefinitionSyntaxError, DimensionalityError, UndefinedUnitError
66

77
from flexmeasures.utils.unit_utils import to_preferred, ur
8-
from flexmeasures.data.models.time_series import Sensor
98

109

1110
class MarshmallowClickMixin(click.ParamType):
@@ -86,39 +85,6 @@ def convert_to_quantity(value: str, to_unit: str) -> ur.Quantity:
8685
)
8786

8887

89-
def extract_sensors_from_flex_config(plot: dict) -> list[Sensor]:
90-
"""
91-
Extracts a consolidated list of sensors from an asset based on
92-
flex-context or flex-model definitions provided in a plot dictionary.
93-
"""
94-
all_sensors = []
95-
96-
from flexmeasures.data.schemas.generic_assets import (
97-
GenericAssetIdField,
98-
) # Import here to avoid circular imports
99-
100-
asset = GenericAssetIdField().deserialize(plot.get("asset"))
101-
102-
fields_to_check = {
103-
"flex-context": asset.flex_context,
104-
"flex-model": asset.flex_model,
105-
}
106-
107-
for plot_key, flex_config in fields_to_check.items():
108-
if plot_key in plot:
109-
field_key = plot[plot_key]
110-
data = flex_config or {}
111-
field_value = data.get(field_key)
112-
113-
if isinstance(field_value, dict):
114-
# Add a single sensor if it exists
115-
sensor = field_value.get("sensor")
116-
if sensor:
117-
all_sensors.append(sensor)
118-
119-
return all_sensors
120-
121-
12288
def snake_to_kebab(key: str) -> str:
12389
"""Convert snake_case to kebab-case."""
12490
return key.replace("_", "-")

flexmeasures/data/tests/test_SensorsToShowSchema.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
from marshmallow import ValidationError
33

4+
from flexmeasures import Sensor
45
from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema
56

67

@@ -47,6 +48,38 @@ def test_dict_with_asset_and_no_title_plot(setup_test_data):
4748
assert schema.deserialize(input_value) == expected_output
4849

4950

51+
def _get_sensor_by_name(sensors: list[Sensor], name: str) -> Sensor:
52+
for sensor in sensors:
53+
if sensor.name == name:
54+
return sensor
55+
raise ValueError(f"Sensor {name} not found")
56+
57+
58+
def test_flatten_with_multiple_flex_config_fields(setup_test_data):
59+
asset = setup_test_data["wind-asset-1"]
60+
schema = SensorsToShowSchema()
61+
input_value = [
62+
{
63+
"plots": [
64+
{
65+
"asset": asset.id,
66+
"flex-model": ["consumption-capacity", "production-capacity"],
67+
"flex-context": "site-consumption-capacity",
68+
}
69+
]
70+
}
71+
]
72+
expected_output = [
73+
_get_sensor_by_name(asset.sensors, name).id
74+
for name in (
75+
"site-consumption-capacity",
76+
"consumption-capacity",
77+
"production-capacity",
78+
)
79+
]
80+
assert schema.flatten(input_value) == expected_output
81+
82+
5083
def test_invalid_sensor_string_input():
5184
schema = SensorsToShowSchema()
5285
with pytest.raises(

flexmeasures/utils/coding_utils.py

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -71,54 +71,6 @@ def sort_dict(unsorted_dict: dict) -> dict:
7171
return sorted_dict
7272

7373

74-
# This function is used for sensors_to_show in follow-up PR it will be moved and renamed to flatten_sensors_to_show
75-
def flatten_unique(nested_list_of_objects: list) -> list:
76-
"""
77-
Get unique sensor IDs from a list of `sensors_to_show`.
78-
79-
Handles:
80-
- Lists of sensor IDs
81-
- Dictionaries with a `sensors` key
82-
- Nested lists (one level)
83-
- Dictionaries with `plots` key containing lists of sensors or asset's flex-config reference
84-
85-
Example:
86-
Input:
87-
[1, [2, 20, 6], 10, [6, 2], {"title":None,"sensors": [10, 15]}, 15, {"plots": [{"sensor": 1}, {"sensors": [20, 6]}]}]
88-
89-
Output:
90-
[1, 2, 20, 6, 10, 15]
91-
"""
92-
all_objects = []
93-
for s in nested_list_of_objects:
94-
if isinstance(s, list):
95-
all_objects.extend(s)
96-
elif isinstance(s, int):
97-
all_objects.append(s)
98-
elif isinstance(s, dict):
99-
if "sensors" in s:
100-
all_objects.extend(s["sensors"])
101-
elif "sensor" in s:
102-
all_objects.append(s["sensor"])
103-
elif "plots" in s:
104-
from flexmeasures.data.schemas.utils import (
105-
extract_sensors_from_flex_config,
106-
)
107-
108-
for entry in s["plots"]:
109-
if "sensors" in entry:
110-
all_objects.extend(entry["sensors"])
111-
if "sensor" in entry:
112-
all_objects.append(entry["sensor"])
113-
if "asset" in entry:
114-
sensors = extract_sensors_from_flex_config(entry)
115-
all_objects.extend(sensors)
116-
else:
117-
all_objects.append(s)
118-
119-
return list(dict.fromkeys(all_objects).keys())
120-
121-
12274
def timeit(func):
12375
"""Decorator for printing the time it took to execute the decorated function."""
12476

0 commit comments

Comments
 (0)