diff --git a/flexmeasures/data/models/charts/__init__.py b/flexmeasures/data/models/charts/__init__.py index a25bd3cf39..266948863b 100644 --- a/flexmeasures/data/models/charts/__init__.py +++ b/flexmeasures/data/models/charts/__init__.py @@ -2,6 +2,7 @@ from . import belief_charts from .defaults import apply_chart_defaults +from .belief_charts import chart_for_flex_config_reference def chart_type_to_chart_specs(chart_type: str, **kwargs) -> dict: @@ -19,6 +20,11 @@ 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) } + + belief_charts_mapping["chart_for_flex_config_reference"] = apply_chart_defaults( + chart_for_flex_config_reference + ) + # 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 1b93173287..6edc7ecf0f 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -1258,3 +1258,99 @@ def chart_for_chargepoint_sessions( ] chart_specs["vconcat"].insert(0, cp_chart) return chart_specs + + +def chart_for_flex_config_reference( + 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, +): + """ + Generate chart specifications for comparing sensors against reference Flex Config values. + + This function renders a vertically concatenated view of line charts for each provided sensor. + It features specific logic for temporary sensors (id < 0), treating them as configuration references: + instead of querying the database, it generates a constant horizontal line based on the + 'graph_value' attribute found in the sensor's metadata. + """ + all_sensors = flatten_unique(sensors_to_show) + + charts = [] + + # Determine legend position based on config + # If combine_legend is True (default in asset view), we usually want a shared legend or + # to let the frontend defaults handle it. Here we force 'bottom' if combined, else 'right'. + legend_orient = "bottom" if combine_legend else "right" + + for sensor in all_sensors: + + # Define the chart + sensor_chart = { + "width": "container", + "height": 150, + "mark": {"type": "line", "point": True, "tooltip": True}, + "encoding": { + "x": { + "field": "event_start", + "type": "temporal", + "scale": { + "domain": ( + [ + event_starts_after.timestamp() * 1000, + event_ends_before.timestamp() * 1000, + ] + if event_starts_after and event_ends_before + else None + ) + }, + "title": None, + "axis": {"labelOverlap": True}, + }, + "y": { + "field": "event_value", + "type": "quantitative", + "title": f"{sensor.name} ({sensor.unit})", + }, + "color": { + "field": "sensor.name", + "type": "nominal", + "legend": {"title": "Sensor", "orient": legend_orient}, + }, + }, + "transform": [{"filter": {"field": "sensor.id", "equal": sensor.id}}], + } + + # SPECIAL HANDLING FOR TEMPORARY SENSORS + if sensor.id < 0: + custom_value = sensor.get_attribute("graph_value", 10) + start_ts = event_starts_after.timestamp() * 1000 + end_ts = event_ends_before.timestamp() * 1000 + manual_data = [] + # loop and add 10 points between start and end + for i in range(11): + manual_data.append( + { + "event_start": start_ts + i * (end_ts - start_ts) / 10, + "event_value": custom_value, + "sensor": {"id": sensor.id, "name": sensor.name}, + "source": {"name": "Simulation"}, + } + ) + + sensor_chart["data"] = {"values": manual_data} + del sensor_chart["transform"] + + charts.append(sensor_chart) + + chart_specs = { + "description": "Custom Sensor View", + "vconcat": charts, + "resolve": {"scale": {"color": "shared", "x": "shared"}}, + } + + for k, v in override_chart_specs.items(): + chart_specs[k] = v + + return chart_specs diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 46bb19383c..86fd9fd318 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 @@ -275,19 +277,10 @@ 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.utils import extract_sensors_from_flex_config from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema sensors_to_show_schema = SensorsToShowSchema() @@ -299,51 +292,41 @@ 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 = [] + asset_refs: list[dict] = [] - for entry in standardized_sensors_to_show: + from flexmeasures.data.schemas.utils import extract_sensors_from_flex_config + 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] = [] 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) + + for plot in plots: + self._process_plot_entry( + plot, + sensors_for_entry, + asset_refs, + extract_sensors_from_flex_config, + Sensor, + ) accessible_sensors = [ accessible_sensor_map.get(sid) - for sid in sensors + for sid in sensors_for_entry if sid in accessible_sensor_map ] - inaccessible = [sid for sid in sensors if sid not in accessible_sensor_map] + + inaccessible = [ + sid for sid in sensors_for_entry 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}]} @@ -353,8 +336,84 @@ 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}." ) + + if asset_refs: + self._add_temporary_asset_sensors(asset_refs, sensors_to_show, Sensor) + 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} for ({sensor.generic_asset.name}))" + sensor.name = sensor_name + + sensors_list.extend(extracted_sensors) + asset_refs_list.extend(refs) + + def _add_temporary_asset_sensors(self, asset_refs, sensors_to_show, SensorClass): + """Helper to create temporary sensor objects for asset references.""" + for ref in asset_refs: + parent_asset = db.session.get(GenericAsset, ref["id"]) + sensor_name = f"Temporary Sensor ({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) + sensors_to_show.append( + {"title": "Temporary Sensor", "plots": [{"sensor": temporary}]} + ) + @property def asset_type(self) -> GenericAssetType: """This property prepares for dropping the "generic" prefix later""" @@ -1044,6 +1103,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 a6e2e0b8df..b1132f8a05 100644 --- a/flexmeasures/data/schemas/generic_assets.py +++ b/flexmeasures/data/schemas/generic_assets.py @@ -243,7 +243,7 @@ def flatten(cls, nested_list) -> list[int]: 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"]) diff --git a/flexmeasures/data/schemas/utils.py b/flexmeasures/data/schemas/utils.py index 235b3dd103..aedd314691 100644 --- a/flexmeasures/data/schemas/utils.py +++ b/flexmeasures/data/schemas/utils.py @@ -4,7 +4,7 @@ from flask.cli import with_appcontext as with_cli_appcontext from pint import DefinitionSyntaxError, DimensionalityError, UndefinedUnitError -from flexmeasures.utils.unit_utils import to_preferred, ur +from flexmeasures.utils.unit_utils import to_preferred, ur, extract_unit_from_string from flexmeasures.data.models.time_series import Sensor @@ -86,12 +86,13 @@ def convert_to_quantity(value: str, to_unit: str) -> ur.Quantity: ) -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 = [] from flexmeasures.data.schemas.generic_assets import ( GenericAssetIdField, @@ -99,6 +100,9 @@ def extract_sensors_from_flex_config(plot: dict) -> list[Sensor]: 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, @@ -115,5 +119,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 = extract_unit_from_string(field_value) + if unit is not None: + asset_refs.append( + { + "id": asset.id, + "field": field_key, + "value": value, + "unit": unit, + } + ) + 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/ui/static/js/components.js b/flexmeasures/ui/static/js/components.js new file mode 100644 index 0000000000..dd8bf6d925 --- /dev/null +++ b/flexmeasures/ui/static/js/components.js @@ -0,0 +1,217 @@ +/** + * 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"); + + addInfo("ID", Asset.id, infoDiv, Asset, true); + infoDiv.appendChild(document.createTextNode(", ")); + addInfo("Name", Asset.name, infoDiv, Asset); + infoDiv.appendChild(document.createTextNode(", ")); + if (IsFlexContext) { + addInfo("Flex Context", flexConfigValue, infoDiv, Asset); + } else if (IsFlexModel) { + addInfo("Flex Model", flexConfigValue, infoDiv, Asset); + } + + 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 col mt-2"; + disabledInput.disabled = true; + const valueToDisplay = assetPlot.flexValue; + 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. + * @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, +) { + 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 = "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"); + + addInfo("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); + } + }); + + flexDiv.appendChild(infoDiv); + 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. + * @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) { + 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, 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 ff571ef500..83d0548042 100644 --- a/flexmeasures/ui/templates/assets/asset_context.html +++ b/flexmeasures/ui/templates/assets/asset_context.html @@ -571,7 +571,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..28e6515ef7 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -42,21 +42,21 @@
- +
@@ -163,33 +163,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 c09b950b8e..a9eed9d8a3 100644 --- a/flexmeasures/ui/templates/assets/asset_properties.html +++ b/flexmeasures/ui/templates/assets/asset_properties.html @@ -484,8 +484,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 11d0a151e9..5458f71484 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 daec0ed067..00f5439ed4 100644 --- a/flexmeasures/ui/views/assets/views.py +++ b/flexmeasures/ui/views/assets/views.py @@ -335,6 +335,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 @@ -347,11 +348,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 0c1607efc6..6b735c6510 100644 --- a/flexmeasures/utils/coding_utils.py +++ b/flexmeasures/utils/coding_utils.py @@ -111,7 +111,7 @@ def flatten_unique(nested_list_of_objects: list) -> list: if "sensor" in entry: all_objects.append(entry["sensor"]) if "asset" in entry: - sensors = extract_sensors_from_flex_config(entry) + sensors, _ = extract_sensors_from_flex_config(entry) all_objects.extend(sensors) else: all_objects.append(s) diff --git a/flexmeasures/utils/unit_utils.py b/flexmeasures/utils/unit_utils.py index 91a8b10087..8dff0da6f0 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 extract_unit_from_string(text: str) -> tuple[str | None, str | None]: + """Extract the unit part from a string representing a quantity, as well as the number value. + + For example: + >>> extract_unit_from_string("1000 kW") + ('1000', 'kW') + >>> extract_unit_from_string("350 EUR/MWh") + ('350', 'EUR/MWh') + >>> extract_unit_from_string("50") + ('50', '') + >>> extract_unit_from_string("kW") + (None, 'kW') + """ + try: + # ur.Quantity parses the number and unit automatically + qty = ur.Quantity(text) + 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,