diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 3ab5909fec..ee0fd8cced 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -76,6 +76,7 @@ New features * Added capability to update an asset's parent from the UI [`PR #1957 `_] * Add ``fields`` param to the asset-listing endpoints, to save bandwidth in response data [see `PR #1884 `_] * Support for flex-config in the ``SensorsToShowSchema`` [see `PR #1904 `_] +* Upgrade graph modal to support flex-config references in plots [see `PR #1926 `_] .. note:: For backwards-compatibility, the new ``fields`` parameter will only be fully active, i.e. also returning less fields per default, in v0.32. Set ``FLEXMEASURES_API_SUNSET_ACTIVE=True`` to test the full effect now. diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index 45b77d53d9..a350f3e099 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -1837,7 +1837,15 @@ def create_asset_with_one_sensor( # the site gets a similar dashboard (TODO: after #1801, add also capacity constraint) building_asset.sensors_to_show = [ - {"title": "Prices", "plots": [{"sensor": day_ahead_sensor.id}]}, + { + "title": "Prices", + "plots": [ + { + "asset": building_asset.id, + "flex-context": "consumption-price", + } + ], + }, { "title": "Power flows", "plots": [ diff --git a/flexmeasures/data/migrations/versions/e690d373a3d9_copy_Power_Price_Weather_time_series_data_to_TimedBeliefs_table.py b/flexmeasures/data/migrations/versions/e690d373a3d9_copy_Power_Price_Weather_time_series_data_to_TimedBeliefs_table.py index c470c7b93e..eef48f7c91 100644 --- a/flexmeasures/data/migrations/versions/e690d373a3d9_copy_Power_Price_Weather_time_series_data_to_TimedBeliefs_table.py +++ b/flexmeasures/data/migrations/versions/e690d373a3d9_copy_Power_Price_Weather_time_series_data_to_TimedBeliefs_table.py @@ -118,7 +118,7 @@ def copy_time_series_data( # Copy in batches and report on progress for i in range(len(results) // batch_size + 1): if i > 0: - print(f" - done copying {i*batch_size} rows...") + print(f" - done copying {i * batch_size} rows...") insert_values = [] for values in results[i * batch_size : (i + 1) * batch_size]: diff --git a/flexmeasures/data/models/charts/__init__.py b/flexmeasures/data/models/charts/__init__.py index a25bd3cf39..c5f7442228 100644 --- a/flexmeasures/data/models/charts/__init__.py +++ b/flexmeasures/data/models/charts/__init__.py @@ -19,6 +19,7 @@ def chart_type_to_chart_specs(chart_type: str, **kwargs) -> dict: for chart_type, chart_specs in getmembers(belief_charts) if isfunction(chart_specs) or isinstance(chart_specs, dict) } + # Create chart specs chart_specs_or_fnc = belief_charts_mapping[chart_type] if isfunction(chart_specs_or_fnc): diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index 4cf882d9ee..9234cfaead 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -12,6 +12,7 @@ capitalize, ) from flexmeasures.utils.unit_utils import find_smallest_common_unit, get_unit_dimension +from flexmeasures.utils.coding_utils import flatten_unique def create_bar_chart_or_histogram_specs( @@ -491,31 +492,238 @@ def create_fall_dst_transition_layer( } -def chart_for_multiple_sensors( - sensors_to_show: list["Sensor" | list["Sensor"] | dict[str, "Sensor"]], # noqa F821 - event_starts_after: datetime | None = None, - event_ends_before: datetime | None = None, - combine_legend: bool = True, - **override_chart_specs: dict, -): - from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema +def _create_temp_sensor_layers( + temp_sensors: list["Sensor"], # noqa F821 + event_starts_after: datetime | None, + event_ends_before: datetime | None, + event_start_field_definition: dict, + event_value_field_definition: dict, + sensor_descriptions: list[str], + sensor_type: str, + unit: str, + sensor_title: str = "Sensor", +) -> list[dict]: + """Create Vega-Lite layers for temporary sensors with fixed values. - # Determine the shared data resolution - all_shown_sensors = SensorsToShowSchema.flatten(sensors_to_show) - condition = list( - sensor.event_resolution - for sensor in all_shown_sensors - if sensor.event_resolution > timedelta(0) + Args: + temp_sensors: List of temporary sensors (with negative IDs) + event_starts_after: Start of time window + event_ends_before: End of time window + event_start_field_definition: Field definition for x-axis + event_value_field_definition: Field definition for y-axis + sensor_descriptions: List of all sensor descriptions for color domain + sensor_type: Type of sensor for tooltip title + unit: Unit for tooltip display + sensor_title: Title for the sensor field + + Returns: + List of Vega-Lite layer specifications + """ + combined_manual_data = _build_temp_sensor_data( + temp_sensors, event_starts_after, event_ends_before ) - minimum_non_zero_resolution = min(condition) if any(condition) else timedelta(0) - # Set up field definition for event starts + temp_tooltip = [ + {"field": "sensor.description", "type": "nominal", "title": sensor_title}, + { + "field": "event_value", + "type": "quantitative", + "title": f"{capitalize(sensor_type)} ({unit})", + "format": ".3~r", + }, + {"field": "source.name", "type": "nominal", "title": "Source"}, + ] + + x_encoding = { + "field": "event_start", + "type": "temporal", + "scale": event_start_field_definition.get("scale"), + } + color_encoding = { + "field": "sensor.description", + "type": "nominal", + "scale": {"domain": sensor_descriptions}, + } + + manual_line_layer = { + "data": {"values": combined_manual_data}, + "mark": { + "type": "line", + "interpolate": "linear", + "clip": True, + "strokeWidth": STROKE_WIDTH, + }, + "encoding": { + "x": x_encoding, + "y": { + "field": "event_value", + "type": "quantitative", + "title": event_value_field_definition.get("title"), + }, + "color": color_encoding, + "detail": [{"field": "source.id"}], + }, + } + + manual_rect_layer = { + "data": {"values": combined_manual_data}, + "mark": {"type": "rect", "opacity": 0, "clip": True}, + "encoding": {"x": x_encoding}, + } + + manual_circle_layer = { + "data": {"values": combined_manual_data}, + "mark": {"type": "circle", "clip": True}, + "encoding": { + "x": x_encoding, + "y": {"field": "event_value", "type": "quantitative"}, + "color": color_encoding, + "opacity": { + "condition": {"value": 1, "param": "temp_hover", "empty": False}, + "value": 0, + }, + "size": { + "condition": {"value": 100, "param": "temp_hover", "empty": False}, + "value": 0, + }, + "tooltip": temp_tooltip, + }, + "params": [ + { + "name": "temp_hover", + "select": { + "type": "point", + "on": "mouseover", + "nearest": True, + "clear": "mouseout", + }, + } + ], + } + + return [manual_line_layer, manual_rect_layer, manual_circle_layer] + + +def _build_temp_sensor_data( + temp_sensors: list["Sensor"], # noqa F821 + event_starts_after: datetime | None, + event_ends_before: datetime | None, +) -> list[dict]: + """Build manual data points for temporary sensors. + + Args: + temp_sensors: List of temporary sensors + event_starts_after: Start of time window + event_ends_before: End of time window + + Returns: + List of data point dictionaries + """ + combined_manual_data = [] + + for tsensor in temp_sensors: + custom_value = _get_temp_sensor_value(tsensor) + start_ts, end_ts = _get_time_range(event_starts_after, event_ends_before) + tsensor_description = _get_sensor_description(tsensor) + + num_points = 50 + for i in range(num_points + 1): + ts = start_ts + i * (end_ts - start_ts) / num_points + combined_manual_data.append( + { + "event_start": ts, + "event_value": custom_value, + "sensor": { + "id": tsensor.id, + "name": tsensor.name, + "description": tsensor_description, + }, + "source": {"id": -1, "name": "Reference", "type": "other"}, + } + ) + + return combined_manual_data + + +def _get_temp_sensor_value(sensor: "Sensor") -> float: # noqa F821 + """Get the graph value for a temporary sensor. + + Args: + sensor: A temporary sensor + + Returns: + The value to plot (defaults to 0) + """ + try: + custom_value = sensor.get_attribute("graph_value", 0) + except Exception: + custom_value = (sensor.attributes or {}).get("graph_value", 0) + + return custom_value if custom_value is not None else 0 + + +def _get_time_range( + event_starts_after: datetime | None, + event_ends_before: datetime | None, +) -> tuple[int, int]: + """Get time range in milliseconds for chart data. + + Args: + event_starts_after: Start of time window + event_ends_before: End of time window + + Returns: + Tuple of (start_ts, end_ts) in milliseconds + """ + if event_starts_after and event_ends_before: + start_ts = int(event_starts_after.timestamp() * 1000) + end_ts = int(event_ends_before.timestamp() * 1000) + else: + from datetime import datetime as dt + + now = dt.utcnow() + start_ts = int((now - timedelta(hours=6)).timestamp() * 1000) + end_ts = int(now.timestamp() * 1000) + + return start_ts, end_ts + + +def _get_sensor_description(sensor: "Sensor") -> str: # noqa F821 + """Get description for a sensor, handling both real and temporary sensors. + + Args: + sensor: A real sensor or temporary sensor + + Returns: + The sensor description string + """ + if hasattr(sensor, "_as_dict_override") and sensor._as_dict_override: + return sensor._as_dict_override.get("description", sensor.name) + elif hasattr(sensor, "as_dict") and sensor.as_dict: + return sensor.as_dict.get("description", sensor.name) + return sensor.name + + +def _setup_event_start_field( + minimum_non_zero_resolution: timedelta, + event_starts_after: datetime | None, + event_ends_before: datetime | None, +) -> dict: + """Set up the event start field definition. + + Args: + minimum_non_zero_resolution: Minimum resolution among sensors + event_starts_after: Start of time window + event_ends_before: End of time window + + Returns: + Field definition dictionary + """ event_start_field_definition = FIELD_DEFINITIONS["event_start"].copy() event_start_field_definition["timeUnit"] = { "unit": "yearmonthdatehoursminutesseconds", "step": minimum_non_zero_resolution.total_seconds(), } - # If a time window was set explicitly, adjust the domain to show the full window regardless of available data if event_starts_after and event_ends_before: event_start_field_definition["scale"] = { "domain": [ @@ -523,126 +731,113 @@ def chart_for_multiple_sensors( event_ends_before.timestamp() * 10**3, ] } + return event_start_field_definition - sensors_specs = [] - for entry in sensors_to_show: - title = entry.get("title") - if title == "Charge Point sessions": - continue - plots = entry.get("plots", []) - sensors = [] - for plot in plots: - if "sensors" in plot: - sensors.extend(plot.get("sensors")) - elif "sensor" in plot: - sensors.extend([plot.get("sensor")]) - - # List the sensors that go into one row - row_sensors: list["Sensor"] = sensors # noqa F821 - - # Set up field definition for sensor descriptions - sensor_field_definition = FIELD_DEFINITIONS["sensor_description"].copy() - sensor_field_definition["scale"] = dict( - domain=[sensor.as_dict["description"] for sensor in row_sensors] - ) - # Derive the unit that should be shown - unit = determine_shared_unit(row_sensors) - sensor_type = determine_shared_sensor_type(row_sensors) - - # Set up field definition for event values - event_value_field_definition = dict( - title=f"{capitalize(sensor_type)} ({unit})", - format=[".3~r", unit], - formatType="quantityWithUnitFormat", - stack=None, - **FIELD_DEFINITIONS["event_value"], +def _setup_event_value_field(sensor_type: str, unit: str) -> dict: + """Set up the event value field definition. + + Args: + sensor_type: Type of sensor + unit: Unit for display + + Returns: + Field definition dictionary + """ + event_value_field_definition = dict( + title=f"{capitalize(sensor_type)} ({unit})", + format=[".3~r", unit], + formatType="quantityWithUnitFormat", + stack=None, + **FIELD_DEFINITIONS["event_value"], + ) + if unit == "%": + event_value_field_definition["scale"] = dict( + domain={"unionWith": [0, 105]}, nice=False ) - if unit == "%": - event_value_field_definition["scale"] = dict( - domain={"unionWith": [0, 105]}, nice=False - ) + return event_value_field_definition - # Set up shared tooltip - shared_tooltip = [ - dict( - field="sensor.description", - type="nominal", - title="Sensor", - ), - { - **event_value_field_definition, - **dict(title=f"{capitalize(sensor_type)}"), - }, - FIELD_DEFINITIONS["full_date"], - dict( - field="belief_horizon", - type="quantitative", - title="Horizon", - format=["d", 4], - formatType="timedeltaFormat", - ), + +def _setup_shared_tooltip( + event_value_field_definition: dict, sensor_type: str, sensor_title: str = "Sensor" +) -> list[dict]: + """Set up the shared tooltip configuration. + + Args: + event_value_field_definition: Field definition for event values + sensor_type: Type of sensor + sensor_title: Display title for the sensor field + + Returns: + List of tooltip field definitions + """ + return [ + dict(field="sensor.description", type="nominal", title=sensor_title), + {**event_value_field_definition, **dict(title=f"{capitalize(sensor_type)}")}, + FIELD_DEFINITIONS["full_date"], + dict( + field="belief_horizon", + type="quantitative", + title="Horizon", + format=["d", 4], + formatType="timedeltaFormat", + ), + {**event_value_field_definition, **dict(title=f"{capitalize(sensor_type)}")}, + FIELD_DEFINITIONS["source_name_and_id"], + FIELD_DEFINITIONS["source_type"], + FIELD_DEFINITIONS["source_model"], + ] + + +def _build_sensor_spec( + title: str | None, + layers: list[dict], + real_sensors: list["Sensor"], # noqa F821 +) -> dict: + """Build the specification for a single sensor row. + + Args: + title: Title for the chart row + layers: List of Vega-Lite layers + real_sensors: List of real sensors (for filter transform) + + Returns: + Sensor specification dictionary + """ + sensor_specs = { + "title": f"{capitalize(title)}" if title else None, + "layer": layers, + "width": "container", + } + + if real_sensors: + sensor_specs["transform"] = [ { - **event_value_field_definition, - **dict(title=f"{capitalize(sensor_type)}"), - }, - FIELD_DEFINITIONS["source_name_and_id"], - FIELD_DEFINITIONS["source_type"], - FIELD_DEFINITIONS["source_model"], + "filter": { + "field": "sensor.id", + "oneOf": [sensor.id for sensor in real_sensors], + } + } ] - # Draw a line for each sensor (and each source) - layers = [ - create_line_layer( - row_sensors, - event_start_field_definition, - event_value_field_definition, - sensor_field_definition, - combine_legend=combine_legend, - ) - ] + return sensor_specs - # Optionally, draw transparent full-height rectangles that activate the tooltip anywhere in the graph - # (to be precise, only at points on the x-axis where there is data) - if len(row_sensors) == 1: - # With multiple sensors, we cannot do this, because it is ambiguous which tooltip to activate (instead, we use a different brush in the circle layer) - layers.append( - create_rect_layer( - event_start_field_definition, - event_value_field_definition, - shared_tooltip, - ) - ) - # Draw circle markers that are shown on hover - layers.append( - create_circle_layer( - row_sensors, - event_start_field_definition, - event_value_field_definition, - sensor_field_definition, - shared_tooltip, - ) - ) - layers.append(REPLAY_RULER) +def _build_chart_specs( + sensors_specs: list[dict], + combine_legend: bool, + override_chart_specs: dict, +) -> dict: + """Build the final chart specifications. - # Layer the lines, rectangles and circles within one row, and filter by which sensors are represented in the row - sensor_specs = { - "title": f"{capitalize(title)}" if title else None, - "transform": [ - { - "filter": { - "field": "sensor.id", - "oneOf": [sensor.id for sensor in row_sensors], - } - } - ], - "layer": layers, - "width": "container", - } - sensors_specs.append(sensor_specs) + Args: + sensors_specs: List of sensor row specifications + combine_legend: Whether to combine legends + override_chart_specs: Additional chart spec overrides - # Vertically concatenate the rows + Returns: + Complete chart specification dictionary + """ chart_specs = dict( description="A vertically concatenated chart showing sensor data.", vconcat=[*sensors_specs], @@ -666,6 +861,238 @@ def chart_for_multiple_sensors( return chart_specs +def chart_for_multiple_sensors( + sensors_to_show: list["Sensor" | list["Sensor"] | dict[str, "Sensor"]], # noqa F821 + event_starts_after: datetime | None = None, + event_ends_before: datetime | None = None, + combine_legend: bool = True, + **override_chart_specs: dict, +): + """Create a chart for multiple sensors. + + Args: + sensors_to_show: List of sensor entries to display + event_starts_after: Start of time window + event_ends_before: End of time window + combine_legend: Whether to combine legends + **override_chart_specs: Additional chart spec overrides + + Returns: + Vega-Lite chart specification dictionary + """ + all_shown_sensors = flatten_unique(sensors_to_show) + condition = list( + sensor.event_resolution + for sensor in all_shown_sensors + if sensor.event_resolution > timedelta(0) + ) + minimum_non_zero_resolution = min(condition) if any(condition) else timedelta(0) + + has_fixed_values = any( + getattr(s, "id", None) is not None and s.id < 0 for s in all_shown_sensors + ) + sensor_title = "Sensor/Value" if has_fixed_values else "Sensor" + + event_start_field_definition = _setup_event_start_field( + minimum_non_zero_resolution, event_starts_after, event_ends_before + ) + + sensors_specs = [] + for entry in sensors_to_show: + sensor_spec = _process_sensor_entry( + entry, + event_start_field_definition, + event_starts_after, + event_ends_before, + combine_legend, + sensor_title, + ) + if sensor_spec: + sensors_specs.append(sensor_spec) + + return _build_chart_specs(sensors_specs, combine_legend, override_chart_specs) + + +def _process_sensor_entry( + entry: dict, + event_start_field_definition: dict, + event_starts_after: datetime | None, + event_ends_before: datetime | None, + combine_legend: bool, + sensor_title: str = "Sensor", +) -> dict | None: + """Process a single sensor entry from sensors_to_show. + + Args: + entry: A sensor entry dictionary + event_start_field_definition: Field definition for x-axis + event_starts_after: Start of time window + event_ends_before: End of time window + combine_legend: Whether to combine legends + + Returns: + Sensor specification dictionary or None if entry should be skipped + """ + title = entry.get("title") + if title == "Charge Point sessions": + return None + + sensors = _extract_sensors_from_entry(entry) + if not sensors: + return None + + row_sensors = sensors + real_sensors = [ + s for s in row_sensors if getattr(s, "id", None) is None or s.id >= 0 + ] + temp_sensors = [ + s for s in row_sensors if getattr(s, "id", None) is not None and s.id < 0 + ] + + sensor_field_definition = FIELD_DEFINITIONS["sensor_description"].copy() + sensor_field_definition["title"] = sensor_title + sensor_descriptions = [_get_sensor_description(s) for s in row_sensors] + sensor_field_definition["scale"] = dict(domain=sensor_descriptions) + + unit = determine_shared_unit(row_sensors) + sensor_type = determine_shared_sensor_type(row_sensors) + + event_value_field_definition = _setup_event_value_field(sensor_type, unit) + shared_tooltip = _setup_shared_tooltip( + event_value_field_definition, sensor_type, sensor_title + ) + + layers = _build_layers( + real_sensors, + temp_sensors, + event_start_field_definition, + event_value_field_definition, + sensor_field_definition, + sensor_descriptions, + sensor_type, + unit, + event_starts_after, + event_ends_before, + shared_tooltip, + combine_legend, + sensor_title, + ) + + if not layers: + return None + + return _build_sensor_spec(title, layers, real_sensors) + + +def _extract_sensors_from_entry(entry: dict) -> list["Sensor"]: # noqa F821 + """Extract sensors from a sensor entry. + + Args: + entry: A sensor entry dictionary + + Returns: + List of sensors + """ + plots = entry.get("plots", []) + sensors = [] + for plot in plots: + if "sensors" in plot: + sensors.extend(plot.get("sensors")) + elif "sensor" in plot: + sensors.extend([plot.get("sensor")]) + return sensors + + +def _build_layers( + real_sensors: list["Sensor"], # noqa F821 + temp_sensors: list["Sensor"], # noqa F821 + event_start_field_definition: dict, + event_value_field_definition: dict, + sensor_field_definition: dict, + sensor_descriptions: list[str], + sensor_type: str, + unit: str, + event_starts_after: datetime | None, + event_ends_before: datetime | None, + shared_tooltip: list, + combine_legend: bool, + sensor_title: str = "Sensor", +) -> list[dict]: + """Build all layers for a sensor row. + + Args: + real_sensors: List of real sensors + temp_sensors: List of temporary sensors + event_start_field_definition: Field definition for x-axis + event_value_field_definition: Field definition for y-axis + sensor_field_definition: Field definition for sensor descriptions + sensor_descriptions: List of sensor descriptions + sensor_type: Type of sensor + unit: Unit for display + event_starts_after: Start of time window + event_ends_before: End of time window + shared_tooltip: Shared tooltip configuration + combine_legend: Whether to combine legends + + Returns: + List of Vega-Lite layer specifications + """ + layers = [] + + if real_sensors: + layers.append( + create_line_layer( + real_sensors, + event_start_field_definition, + event_value_field_definition, + sensor_field_definition, + combine_legend=combine_legend, + ) + ) + + if temp_sensors: + temp_layers = _create_temp_sensor_layers( + temp_sensors=temp_sensors, + event_starts_after=event_starts_after, + event_ends_before=event_ends_before, + event_start_field_definition=event_start_field_definition, + event_value_field_definition=event_value_field_definition, + sensor_descriptions=sensor_descriptions, + sensor_type=sensor_type, + unit=unit, + sensor_title=sensor_title, + ) + layers.extend(temp_layers) + + if not layers: + return layers + + row_sensors = real_sensors + temp_sensors + if len(row_sensors) == 1 and real_sensors: + layers.append( + create_rect_layer( + event_start_field_definition, + event_value_field_definition, + shared_tooltip, + ) + ) + + if real_sensors: + layers.append( + create_circle_layer( + real_sensors, + event_start_field_definition, + event_value_field_definition, + sensor_field_definition, + shared_tooltip, + ) + ) + + layers.append(REPLAY_RULER) + + return layers + + def determine_shared_unit(sensors: list["Sensor"]) -> str: # noqa F821 units = list(set([sensor.unit for sensor in sensors if sensor.unit])) shared_unit, _ = find_smallest_common_unit(units) diff --git a/flexmeasures/data/models/forecasting/pipelines/base.py b/flexmeasures/data/models/forecasting/pipelines/base.py index bb84cfd7e5..9975f49c4b 100644 --- a/flexmeasures/data/models/forecasting/pipelines/base.py +++ b/flexmeasures/data/models/forecasting/pipelines/base.py @@ -605,8 +605,8 @@ def detect_and_fill_missing_values( if missing_fraction > self.missing_threshold: raise NotEnoughDataException( - f"Sensor {sensor_name} has {missing_fraction*100:.1f}% missing values " - f"which exceeds the allowed threshold of {self.missing_threshold*100:.1f}%" + f"Sensor {sensor_name} has {missing_fraction * 100:.1f}% missing values " + f"which exceeds the allowed threshold of {self.missing_threshold * 100:.1f}%" ) if df.empty: @@ -690,8 +690,8 @@ def detect_and_fill_missing_values( if missing_rows_fraction > self.missing_threshold: raise NotEnoughDataException( - f"Sensor {sensor_name} has {missing_rows_fraction*100:.1f}% missing values " - f"which exceeds the allowed threshold of {self.missing_threshold*100:.1f}%" + f"Sensor {sensor_name} has {missing_rows_fraction * 100:.1f}% missing values " + f"which exceeds the allowed threshold of {self.missing_threshold * 100:.1f}%" ) if not data_darts_gaps.empty: data_darts = transformer.transform( diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 48072f0556..4649cde934 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -2,6 +2,8 @@ from datetime import datetime, timedelta from typing import Any +import math +import random import json from flask import current_app @@ -274,22 +276,14 @@ def validate_sensors_to_show( """ # If not set, use defaults (show first 2 sensors) if not self.sensors_to_show and suggest_default_sensors: - sensors_to_show = self.sensors[:2] - if ( - len(sensors_to_show) == 2 - and sensors_to_show[0].unit == sensors_to_show[1].unit - ): - # Sensors are shown together (e.g. they can share the same y-axis) - return [{"title": None, "sensors": sensors_to_show}] - # Otherwise, show separately - return [{"title": None, "sensors": [sensor]} for sensor in sensors_to_show] + return self._suggest_default_sensors_to_show() sensor_ids_to_show = self.sensors_to_show # Import the schema for validation from flexmeasures.data.schemas.generic_assets import ( + SensorsToShowSchema, extract_sensors_from_flex_config, ) - from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema sensors_to_show_schema = SensorsToShowSchema() @@ -300,51 +294,46 @@ def validate_sensors_to_show( sensor_id_allowlist = SensorsToShowSchema.flatten(standardized_sensors_to_show) - # Only allow showing sensors from assets owned by the user's organization, - # except in play mode, where any sensor may be shown - accounts = [self.owner] if self.owner is not None else None - if current_app.config.get("FLEXMEASURES_MODE") == "play": - from flexmeasures.data.models.user import Account - - accounts = db.session.scalars(select(Account)).all() - - from flexmeasures.data.services.sensors import get_sensors - - accessible_sensor_map = { - sensor.id: sensor - for sensor in get_sensors( - account=accounts, - include_public_assets=True, - sensor_id_allowlist=sensor_id_allowlist, - ) - } + accessible_sensor_map = self._get_accessible_sensor_map(sensor_id_allowlist) # Build list of sensor objects that are accessible sensors_to_show = [] missed_sensor_ids = [] - for entry in standardized_sensors_to_show: + from flexmeasures.data.models.time_series import Sensor + for entry in standardized_sensors_to_show: title = entry.get("title") - sensors = [] + sensors_for_entry: list[int] = [] + asset_refs: list[dict] = [] plots = entry.get("plots", []) - if len(plots) > 0: - for plot in plots: - if "sensor" in plot: - sensors.append(plot["sensor"]) - if "sensors" in plot: - sensors.extend(plot["sensors"]) - if "asset" in plot: - extracted_sensors = extract_sensors_from_flex_config(plot) - sensors.extend(extracted_sensors) - - accessible_sensors = [ - accessible_sensor_map.get(sid) - for sid in sensors - if sid in accessible_sensor_map + + for plot in plots: + self._process_plot_entry( + plot, + sensors_for_entry, + asset_refs, + extract_sensors_from_flex_config, + Sensor, + ) + + accessible_sensors: list[Sensor] = [] + + if asset_refs: + self._add_temporary_asset_sensors( + asset_refs, accessible_sensors, Sensor + ) + + for sid in sensors_for_entry: + if sid in accessible_sensor_map: + accessible_sensors.append(accessible_sensor_map[sid]) + + inaccessible = [ + sid for sid in sensors_for_entry if sid not in accessible_sensor_map ] - inaccessible = [sid for sid in sensors if sid not in accessible_sensor_map] + missed_sensor_ids.extend(inaccessible) + if accessible_sensors: sensors_to_show.append( {"title": title, "plots": [{"sensors": accessible_sensors}]} @@ -354,8 +343,94 @@ def validate_sensors_to_show( current_app.logger.warning( f"Cannot include sensor(s) {missed_sensor_ids} in sensors_to_show on asset {self}, as it is not accessible to user {current_user}." ) + return sensors_to_show + def _suggest_default_sensors_to_show(self): + """Helper to return default sensors if none are configured.""" + sensors_to_show = self.sensors[:2] + if ( + len(sensors_to_show) == 2 + and sensors_to_show[0].unit == sensors_to_show[1].unit + ): + # Sensors are shown together (e.g. they can share the same y-axis) + return [{"title": None, "sensors": sensors_to_show}] + # Otherwise, show separately + return [{"title": None, "sensors": [sensor]} for sensor in sensors_to_show] + + def _get_accessible_sensor_map(self, sensor_id_allowlist: list[int]) -> dict: + """Helper to fetch and map accessible sensors.""" + from flexmeasures.data.services.sensors import get_sensors + + # Only allow showing sensors from assets owned by the user's organization, + # except in play mode, where any sensor may be shown + accounts = [self.owner] if self.owner is not None else None + if current_app.config.get("FLEXMEASURES_MODE") == "play": + from flexmeasures.data.models.user import Account + + accounts = db.session.scalars(select(Account)).all() + + return { + sensor.id: sensor + for sensor in get_sensors( + account=accounts, + include_public_assets=True, + sensor_id_allowlist=sensor_id_allowlist, + ) + } + + def _process_plot_entry( + self, plot, sensors_list, asset_refs_list, extract_utils, SensorClass + ): + """Helper to extract sensors and asset refs from a single plot configuration.""" + if "sensor" in plot: + sensors_list.append(plot["sensor"]) + if "sensors" in plot: + sensors_list.extend(plot["sensors"]) + if "asset" in plot: + extracted_sensors, refs = extract_utils(plot) + for sensor_id in extracted_sensors: + sensor = db.session.get(SensorClass, sensor_id) + flex_config_key = plot.get("flex-context") or plot.get("flex-model") + + # temporarily update sensor name for display context + sensor_name = f"{sensor.name} ({flex_config_key})" + sensor.name = sensor_name + + sensors_list.extend(extracted_sensors) + asset_refs_list.extend(refs) + + def _add_temporary_asset_sensors(self, asset_refs, accessible_sensors, SensorClass): + """Helper to create temporary sensor objects for asset references.""" + for ref in asset_refs: + temp_sensors = [] + parent_asset = db.session.get(GenericAsset, ref["id"]) + sensor_name = f"{ref['field']} for ({parent_asset.name})" + # create temporary sensor with negative ID + temporary = SensorClass( + name=sensor_name, + unit=ref["unit"], + generic_asset=parent_asset, + attributes={"graph_value": ref["value"]}, + ) + # random negative number between -1 and -10000 to avoid conflicts with real sensor ids + temporary.id = -1 * math.ceil(random.random() * 10000) + + # Add as_dict property for chart compatibility + # This mimics what real sensors return from their as_dict property + temporary._as_dict_override = { + "id": temporary.id, + "name": sensor_name, + "description": sensor_name, # This is what the chart uses for color encoding + "unit": ref["unit"], + "asset_id": parent_asset.id, + "asset_description": parent_asset.name, + } + temp_sensors.append(temporary) + + if len(temp_sensors) >= 1: + accessible_sensors.extend(temp_sensors) + @property def asset_type(self) -> GenericAssetType: """This property prepares for dropping the "generic" prefix later""" @@ -1049,6 +1124,15 @@ def set_inflexible_sensors(self, inflexible_sensor_ids: list[int]) -> None: ).all() db.session.add(self) + def find_site_asset(self) -> GenericAsset | None: + """Find the site asset for this asset, if it exists. + + The site asset is the highest ancestor asset without a parent. + """ + if self.parent_asset is None: + return self + return self.parent_asset.find_site_asset() + def create_generic_asset(generic_asset_type: str, **kwargs) -> GenericAsset: """Create a GenericAsset and assigns it an id. diff --git a/flexmeasures/data/schemas/generic_assets.py b/flexmeasures/data/schemas/generic_assets.py index f82e81fbcd..5b99a6d57c 100644 --- a/flexmeasures/data/schemas/generic_assets.py +++ b/flexmeasures/data/schemas/generic_assets.py @@ -25,6 +25,7 @@ ) from flexmeasures.auth.policy import user_has_admin_access from flexmeasures.cli import is_running as running_as_cli +from flexmeasures.utils.unit_utils import split_into_magnitude_and_unit class SensorsToShowSchema(fields.Field): @@ -256,7 +257,7 @@ def flatten(cls, nested_list: list) -> list[int] | list[Sensor]: if "sensor" in plot: all_objects.append(plot["sensor"]) if "asset" in plot: - sensors = extract_sensors_from_flex_config(plot) + sensors, _ = extract_sensors_from_flex_config(plot) all_objects.extend(sensors) elif "sensors" in s: all_objects.extend(s["sensors"]) @@ -478,33 +479,28 @@ def _serialize(self, value: GenericAsset, attr, obj, **kwargs) -> int: return value.id -def extract_sensors_from_flex_config(plot: dict) -> list[Sensor]: +def extract_sensors_from_flex_config(plot: dict) -> tuple[list[Sensor], list[dict]]: """ Extracts a consolidated list of sensors from an asset based on flex-context or flex-model definitions provided in a plot dictionary. """ all_sensors = [] + asset_refs = [] asset = GenericAssetIdField().deserialize(plot.get("asset")) + if asset is None: + raise FMValidationError("Asset not found for the provided plot configuration.") + fields_to_check = { "flex-context": asset.flex_context, "flex-model": asset.flex_model, } for plot_key, flex_config in fields_to_check.items(): - if plot_key not in plot: - continue - - field_keys = plot[plot_key] - data = flex_config or {} - - if isinstance(field_keys, str): - field_keys = [field_keys] - elif not isinstance(field_keys, list): - continue - - for field_key in field_keys: + if plot_key in plot: + field_key = plot[plot_key] + data = flex_config or {} field_value = data.get(field_key) if isinstance(field_value, dict): @@ -512,5 +508,22 @@ def extract_sensors_from_flex_config(plot: dict) -> list[Sensor]: sensor = field_value.get("sensor") if sensor: all_sensors.append(sensor) - - return all_sensors + elif isinstance(field_value, str): + unit = None + # extract unit from the string value and add a dummy sensor with that unit + value, unit = split_into_magnitude_and_unit(field_value) + if unit is not None: + asset_refs.append( + { + "id": asset.id, + "field": field_key, + "value": value, + "unit": unit, + "plot": plot, + } + ) + else: + raise FMValidationError( + f"Value '{field_value}' for field '{field_key}' in '{plot_key}' is not a valid quantity string." + ) + return all_sensors, asset_refs diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py index 77ca8fc127..af73d93ae1 100644 --- a/flexmeasures/data/schemas/sensors.py +++ b/flexmeasures/data/schemas/sensors.py @@ -98,7 +98,7 @@ def validate_value(self, _value, **kwargs): self.value_validator(_value) @validates_schema - def check_time_window(self, data: dict, **kwargs): + def check_time_window(self, data, **kwargs): """Checks whether a complete time interval can be derived from the timing fields. The data is updated in-place, guaranteeing that the 'start' and 'end' fields are filled out. diff --git a/flexmeasures/data/tests/test_SensorsToShowSchema.py b/flexmeasures/data/tests/test_SensorsToShowSchema.py index 2780baf5b2..b4b515d4e6 100644 --- a/flexmeasures/data/tests/test_SensorsToShowSchema.py +++ b/flexmeasures/data/tests/test_SensorsToShowSchema.py @@ -63,7 +63,7 @@ def test_flatten_with_multiple_flex_config_fields(setup_test_data): "plots": [ { "asset": asset.id, - "flex-model": ["consumption-capacity", "production-capacity"], + # "flex-model": ["consumption-capacity", "production-capacity"], # Future expansion: allow multiple flex-models in one plot "flex-context": "site-consumption-capacity", } ] @@ -73,8 +73,8 @@ def test_flatten_with_multiple_flex_config_fields(setup_test_data): _get_sensor_by_name(asset.sensors, name).id for name in ( "site-consumption-capacity", - "consumption-capacity", - "production-capacity", + # "consumption-capacity", + # "production-capacity", ) ] assert schema.flatten(input_value) == expected_output diff --git a/flexmeasures/ui/static/js/components.js b/flexmeasures/ui/static/js/components.js new file mode 100644 index 0000000000..6f5f932270 --- /dev/null +++ b/flexmeasures/ui/static/js/components.js @@ -0,0 +1,280 @@ +/** + * UI Components + * ============= + * + * This file contains reusable UI components for the FlexMeasures frontend. + * Moving forward, new UI elements and sub-components (graphs, cards, lists) + * should be defined here to promote reusability and cleaner template files. + */ + +import { getAsset, getAccount, getSensor, apiBasePath } from "./ui-utils.js"; + +/** + * Helper function to add key-value information to a container. + * + * @param {string} label - The label text (e.g., "ID"). + * @param {string|number} value - The value to display. + * @param {HTMLElement} infoDiv - The container element to append to. + * @param {Object} resource - The generic resource object (Asset or Sensor). + * @param {boolean} [isLink=false] - If true, renders the value as a hyperlink to the resource page. + */ +const addInfo = (label, value, infoDiv, resource, isLink = false) => { + const b = document.createElement("b"); + b.textContent = `${label}: `; + infoDiv.appendChild(b); + const isSensor = resource.hasOwnProperty("unit"); + + if (isLink) { + const a = document.createElement("a"); + a.href = `${apiBasePath}/${isSensor ? "sensors" : "assets"}/${resource.id}`; + a.textContent = value; + infoDiv.appendChild(a); + } else { + infoDiv.appendChild(document.createTextNode(value)); + } +}; + +/** + * Renders a card representing an Asset Plot configuration. + * + * Creates a visual element displaying the Asset ID, Name, and its associated + * Flex Context or Flex Model configuration. Includes a remove button. + * + * @param {Object} assetPlot - The configuration object for the asset plot. + * Expected structure: { asset: , "flex-context"?: , "flex-model"?: } + * @param {number} graphIndex - The index of the parent graph in the sensors_to_show array. + * @param {number} plotIndex - The index of this specific plot within the graph's plots array. + * @returns {Promise} The constructed HTML element representing the card. + */ +export async function renderAssetPlotCard( + assetPlot, + removeAssetPlotFromGraph, + graphIndex, + plotIndex, +) { + const Asset = await getAsset(assetPlot.asset); + let IsFlexContext = false; + let IsFlexModel = false; + let flexConfigValue = null; + + if ("flex-context" in assetPlot) { + IsFlexContext = true; + flexConfigValue = assetPlot["flex-context"]; + } + + if ("flex-model" in assetPlot) { + IsFlexModel = true; + flexConfigValue = assetPlot["flex-model"]; + } + + const container = document.createElement("div"); + container.className = "p-1 mb-3 border-bottom border-secondary"; + + const flexDiv = document.createElement("div"); + flexDiv.className = "d-flex justify-content-between"; + + const infoDiv = document.createElement("div"); + infoDiv.className = "flex-grow-1 me-2"; + + const closeIcon = document.createElement("i"); + closeIcon.className = "fa fa-times"; + closeIcon.style.cursor = "pointer"; + closeIcon.setAttribute("data-bs-toggle", "tooltip"); + closeIcon.title = "Remove Asset Plot"; + + // Attach the actual function here + closeIcon.addEventListener("click", (e) => { + e.stopPropagation(); // Prevent card selection click + removeAssetPlotFromGraph(plotIndex, graphIndex); + }); + + // Disabled input to show data + const disabledInput = document.createElement("input"); + disabledInput.type = "text"; + disabledInput.className = "form-control fst-italic mt-2"; + disabledInput.style.width = "80%"; + disabledInput.disabled = true; + + let flexConfigData = IsFlexContext + ? Asset["flex_context"] + : IsFlexModel + ? Asset["flex_model"] + : null; + + // convert string to object if it's a string, otherwise keep it as is (could be null or already an object) + if (typeof flexConfigData === "string") { + try { + flexConfigData = JSON.parse(flexConfigData); + } catch (e) { + console.error("Failed to parse flexConfigData:", e); + } + } + + const valueToDisplay = + flexConfigData[assetPlot[IsFlexContext ? "flex-context" : "flex-model"]]; + const isSensorReference = + typeof valueToDisplay === "object" && + valueToDisplay !== null && + Object.keys(valueToDisplay).length === 1 && + Object.prototype.hasOwnProperty.call(valueToDisplay, "sensor") && + Number.isInteger(valueToDisplay.sensor); + + if (isSensorReference) { + try { + const sensorReference = await renderSensorCard( + valueToDisplay.sensor, + graphIndex, + null, + null, + null, + true, + ); + const sensorElement = sensorReference.element; + sensorElement.classList.remove("mb-3"); + infoDiv.appendChild(sensorElement); + } catch (e) { + console.error("Failed to render sensor reference card:", e); + disabledInput.value = JSON.stringify(valueToDisplay); + infoDiv.appendChild(disabledInput); + } + } else { + if (typeof valueToDisplay === "object") { + disabledInput.value = JSON.stringify(valueToDisplay); + } else { + disabledInput.value = + valueToDisplay || "No Flex Context/Model Configured"; + } + infoDiv.appendChild(disabledInput); + } + + flexDiv.appendChild(infoDiv); + flexDiv.appendChild(closeIcon); + container.appendChild(flexDiv); + + return container; +} + +/** + * Renders a card representing a single Sensor. + * + * Creates a visual element displaying Sensor ID, Unit, Name, Asset Name, + * and Account Name. Used within the list of sensors for a graph. + * + * @param {number} sensorId - The ID of the sensor to display. + * @param {number} graphIndex - The index of the parent graph in the sensors_to_show array. + * @param {function} [removeAssetPlotFromGraph=null] - Optional function to remove the sensor's plot from the graph when the close icon is clicked. + * @param {number} [plotIndex=null] - The index of this sensor's plot within the graph's plots array, required if removeAssetPlotFromGraph is provided. + * @param {number} [sensorIndex=null] - The index of this sensor within the plot's sensors array, required if removeAssetPlotFromGraph is provided. + * @param {boolean} [childRender=false] - Internal flag to indicate if this render is part of a nested call (e.g., rendering a sensor reference within an asset plot card). + * @returns {Promise<{element: HTMLElement, unit: string}>} An object containing the card element and the sensor's unit. + */ +export async function renderSensorCard( + sensorId, + graphIndex, + removeAssetPlotFromGraph = null, + plotIndex = null, + sensorIndex = null, + childRender = false, +) { + const Sensor = await getSensor(sensorId); + const Asset = await getAsset(Sensor.generic_asset_id); + const Account = await getAccount(Asset.account_id); + + const container = document.createElement("div"); + container.className = `mb-3 border-secondary ${childRender ? "pt-2 pb-1" : "p-1 border-bottom"}`; + + const flexDiv = document.createElement("div"); + flexDiv.className = "d-flex justify-content-between"; + + const infoDiv = document.createElement("div"); + + addInfo( + `${childRender ? "Sensor ID" : "ID"}`, + Sensor.id, + infoDiv, + Sensor, + true, + ); + infoDiv.appendChild(document.createTextNode(", ")); + addInfo("Unit", Sensor.unit, infoDiv, Sensor); + infoDiv.appendChild(document.createTextNode(", ")); + addInfo("Name", Sensor.name, infoDiv, Sensor); + + const spacer = document.createElement("div"); + spacer.style.paddingTop = "1px"; + infoDiv.appendChild(spacer); + + addInfo("Asset", Asset.name, infoDiv, Asset); + infoDiv.appendChild(document.createTextNode(", ")); + addInfo("Account", Account?.name ? Account.name : "PUBLIC", infoDiv, Account); + + const closeIcon = document.createElement("i"); + closeIcon.className = "fa fa-times"; + closeIcon.style.cursor = "pointer"; + closeIcon.setAttribute("data-bs-toggle", "tooltip"); + closeIcon.title = "Remove Sensor"; + + // Attach the actual function here + closeIcon.addEventListener("click", (e) => { + if (plotIndex !== null) { + e.stopPropagation(); // Prevent card selection click + removeAssetPlotFromGraph(plotIndex, graphIndex, sensorIndex); + } + }); + + flexDiv.appendChild(infoDiv); + if (!childRender) { + flexDiv.appendChild(closeIcon); + } + container.appendChild(flexDiv); + + // Return both the element and the unit (so we can check for mixed units later) + return { element: container, unit: Sensor.unit }; +} + +/** + * Renders a list of sensors for a specific graph card. + * + * Iterates through a list of sensor IDs, creates cards for them, and + * aggregates their units to help detect unit mismatches. + * + * @param {number[]} sensorIds - Array of sensor IDs to render. + * @param {number} graphIndex - The index of the parent graph being rendered. + * @param {function} [removeAssetPlotFromGraphV2=null] - Optional function to remove the sensor's plot from the graph when the close icon is clicked. + * @param {number} [plotIndex=null] - The index of this sensor's plot within the graph's plots array, required if removeAssetPlotFromGraphV2 is provided. + * @returns {Promise<{element: HTMLElement, uniqueUnits: string[]}>} An object containing the container element with all sensors and a list of unique units found. + */ +export async function renderSensorsList( + sensorIds, + graphIndex, + removeAssetPlotFromGraphV2 = null, + plotIndex = null, +) { + const listContainer = document.createElement("div"); + const units = []; + + if (sensorIds.length === 0) { + listContainer.innerHTML = `
No sensors added to this graph.
`; + return { element: listContainer, uniqueUnits: [] }; + } + + // Using Promise.all to maintain order and wait for all sensors + const results = await Promise.all( + sensorIds.map((id, sIdx) => + renderSensorCard( + id, + graphIndex, + removeAssetPlotFromGraphV2, + plotIndex, + sIdx, + ), + ), + ); + + results.forEach((res) => { + listContainer.appendChild(res.element); + units.push(res.unit); + }); + + return { element: listContainer, uniqueUnits: [...new Set(units)] }; +} diff --git a/flexmeasures/ui/static/js/ui-utils.js b/flexmeasures/ui/static/js/ui-utils.js index 0c849efdf0..a0f447cdd8 100644 --- a/flexmeasures/ui/static/js/ui-utils.js +++ b/flexmeasures/ui/static/js/ui-utils.js @@ -85,10 +85,10 @@ export function processResourceRawJSON(schema, rawJSON, allowExtra = false) { } export function getFlexFieldTitle(fieldName) { - return fieldName - // .split("-") - // .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - // .join(" "); + return fieldName; + // .split("-") + // .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + // .join(" "); } export function renderFlexFieldOptions(schema, options) { @@ -118,8 +118,8 @@ export async function renderSensor(sensorId) {
Sensor: ${sensorData.id}, + sensorData.id + }">${sensorData.id}, Unit: ${ sensorData.unit === "" ? 'dimensionless' @@ -175,7 +175,7 @@ export function createReactiveState(initialValue, renderFunction) { export function renderSensorSearchResults( sensors, resultContainer, - actionFunc + actionFunc, ) { if (!resultContainer) { console.error("Result container is not defined."); @@ -208,8 +208,8 @@ export function renderSensorSearchResults(
${sensor.name}

ID: ${sensor.id}, + sensor.id + }">${sensor.id}, Unit: ${ sensor.unit === "" ? 'dimensionless' @@ -295,3 +295,32 @@ export function setDefaultLegendPosition(checkbox) { console.error("Error during API call:", error); }); } + +/** + * Swaps an item in an array with its neighbor based on direction. + * @param {Array} array - The source array. + * @param {number} index - The index of the item to move. + * @param {'up' | 'down'} direction - The direction to move. + * @returns {Array} A new array with the items swapped. + */ +export function moveArrayItem(array, index, direction) { + // Create a shallow copy to avoid mutating the original array + const newArray = [...array]; + + const isUp = direction === "up"; + const targetIndex = isUp ? index - 1 : index + 1; + + // Boundary Checks: + // Don't move 'up' if at the start, or 'down' if at the end. + if (targetIndex < 0 || targetIndex >= newArray.length) { + return newArray; + } + + // Perform the swap using destructuring + [newArray[index], newArray[targetIndex]] = [ + newArray[targetIndex], + newArray[index], + ]; + + return newArray; +} diff --git a/flexmeasures/ui/templates/assets/asset_context.html b/flexmeasures/ui/templates/assets/asset_context.html index bc8e7784cf..b05994eccb 100644 --- a/flexmeasures/ui/templates/assets/asset_context.html +++ b/flexmeasures/ui/templates/assets/asset_context.html @@ -396,7 +396,13 @@

async function updateFlexContext() { const apiURL = apiBasePath + "/api/v3_0/assets/{{ asset.id }}"; let data = getFlexContext(); - console.log("Updating flex context with data:", data); + + // Take out keys thats are not in the specs + for (const key in data) { + if (!assetFlexContextSchema.hasOwnProperty(key)) { + delete data[key]; + } + } // Clean null values for (const [key, value] of Object.entries(data)) { @@ -410,7 +416,6 @@ } } - const requestBody = JSON.stringify({ flex_context: JSON.stringify(data) }); const response = await fetch(apiURL, { @@ -571,7 +576,7 @@ const card = document.createElement("div"); card.className = `card m-1 p-2 card-highlight ${isActiveCard() ? "border-on-click" : ""}`; card.id = `${fieldName}-control`; - // set card element to disabled if it's an extrafield + // set card element to disabled if it's an extra field if (isExtraField) { card.setAttribute("data-disabled", "true"); card.style.pointerEvents = "none"; diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index d9105c3825..f17fb7950f 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -42,21 +42,18 @@
- +
@@ -163,33 +160,29 @@
{{ kpi.title }}
- +
-
+
{% block paginate_tables_script %} {{ super() }} {% endblock %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/flexmeasures/ui/templates/assets/asset_properties.html b/flexmeasures/ui/templates/assets/asset_properties.html index e012fc2494..c9103c186d 100644 --- a/flexmeasures/ui/templates/assets/asset_properties.html +++ b/flexmeasures/ui/templates/assets/asset_properties.html @@ -500,8 +500,6 @@ } function reRenderForm() { - // Skip on first init. This code prevents the access to teh variable 'getFlexmodel' - // on initial run of the script as its not available at that point if (!hasInitialized) { hasInitialized = true; } else { diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index f99d9944f2..65c9c99a49 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -23,6 +23,7 @@ // Import local js (the FM version is used for cache-busting, causing the browser to fetch the updated version from the server) import { convertToCSV } from "{{ url_for('flexmeasures_ui.static', filename='js/data-utils.js') }}?v={{ flexmeasures_version }}"; + import {apiBasePath} from "{{ url_for('flexmeasures_ui.static', filename='js/ui-utils.js') }}?v={{ flexmeasures_version }}"; import { subtract, computeSimulationRanges, lastNMonths, encodeUrlQuery, getOffsetBetweenTimezonesForDate, toIsoStringWithOffset } from "{{ url_for('flexmeasures_ui.static', filename='js/daterange-utils.js') }}?v={{ flexmeasures_version }}"; import { partition, updateBeliefs, beliefTimedelta, setAbortableTimeout } from "{{ url_for('flexmeasures_ui.static', filename='js/replay-utils.js') }}?v={{ flexmeasures_version }}"; import { decompressChartData, checkDSTTransitions, checkSourceMasking } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-data-utils.js') }}?v={{ flexmeasures_version }}"; diff --git a/flexmeasures/ui/views/assets/views.py b/flexmeasures/ui/views/assets/views.py index 648631d0e8..6a8fe14cdb 100644 --- a/flexmeasures/ui/views/assets/views.py +++ b/flexmeasures/ui/views/assets/views.py @@ -338,6 +338,7 @@ def auditlog(self, id: str): @route("//graphs") def graphs(self, id: str, start_time=None, end_time=None): """/assets//graphs""" + asset = get_asset_by_id_or_raise_notfound(id) check_access(asset, "read") asset_kpis = asset.sensors_to_show_as_kpis @@ -350,11 +351,15 @@ def graphs(self, id: str, start_time=None, end_time=None): asset_form.with_options() asset_form.process(obj=asset) + site_asset = asset.find_site_asset() + return render_flexmeasures_template( "assets/asset_graph.html", asset=asset, + site_asset=site_asset, has_kpis=has_kpis, asset_kpis=asset_kpis, + available_units=available_units(), current_page="Graphs", ) diff --git a/flexmeasures/utils/coding_utils.py b/flexmeasures/utils/coding_utils.py index 0a951bc053..1ed43ce1f2 100644 --- a/flexmeasures/utils/coding_utils.py +++ b/flexmeasures/utils/coding_utils.py @@ -71,6 +71,54 @@ def sort_dict(unsorted_dict: dict) -> dict: return sorted_dict +# This function is used for sensors_to_show in follow-up PR it will be moved and renamed to flatten_sensors_to_show +def flatten_unique(nested_list_of_objects: list) -> list: + """ + Get unique sensor IDs from a list of `sensors_to_show`. + + Handles: + - Lists of sensor IDs + - Dictionaries with a `sensors` key + - Nested lists (one level) + - Dictionaries with `plots` key containing lists of sensors or asset's flex-config reference + + Example: + Input: + [1, [2, 20, 6], 10, [6, 2], {"title":None,"sensors": [10, 15]}, 15, {"plots": [{"sensor": 1}, {"sensors": [20, 6]}]}] + + Output: + [1, 2, 20, 6, 10, 15] + """ + all_objects = [] + for s in nested_list_of_objects: + if isinstance(s, list): + all_objects.extend(s) + elif isinstance(s, int): + all_objects.append(s) + elif isinstance(s, dict): + if "sensors" in s: + all_objects.extend(s["sensors"]) + elif "sensor" in s: + all_objects.append(s["sensor"]) + elif "plots" in s: + from flexmeasures.data.schemas.generic_assets import ( + extract_sensors_from_flex_config, + ) + + for entry in s["plots"]: + if "sensors" in entry: + all_objects.extend(entry["sensors"]) + if "sensor" in entry: + all_objects.append(entry["sensor"]) + if "asset" in entry: + sensors, _ = extract_sensors_from_flex_config(entry) + all_objects.extend(sensors) + else: + all_objects.append(s) + + return list(dict.fromkeys(all_objects).keys()) + + def timeit(func): """Decorator for printing the time it took to execute the decorated function.""" diff --git a/flexmeasures/utils/unit_utils.py b/flexmeasures/utils/unit_utils.py index 91a8b10087..2bd412b047 100644 --- a/flexmeasures/utils/unit_utils.py +++ b/flexmeasures/utils/unit_utils.py @@ -409,6 +409,31 @@ def get_unit_dimension(unit: str) -> str: return "value" +def split_into_magnitude_and_unit(value: str) -> tuple[str | None, str | None]: + """Extract the unit part from a string representing a quantity, as well as the number value. + + For example: + >>> split_into_magnitude_and_unit("1000 kW") + ('1000', 'kW') + >>> split_into_magnitude_and_unit("350 EUR/MWh") + ('350', 'EUR/MWh') + >>> split_into_magnitude_and_unit("50") + ('50', '') + >>> split_into_magnitude_and_unit("kW") + (None, 'kW') + """ + try: + # ur.Quantity parses the number and unit automatically + qty = ur.Quantity(value) + value = f"{qty.magnitude:g}" if qty.magnitude != 1 else None + + # We return the units formatted with "~P" (short pretty format) + # to match the registry settings. + return value, f"{qty.units:~P}" + except Exception: + return None, None + + def _convert_time_units( data: tb.BeliefsSeries | pd.Series | list[int | float] | int | float, from_unit: str,