From 1f53e91812afbcb0a1b3f764114464c5c9464b10 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 31 Oct 2026 21:50:14 +0100 Subject: [PATCH 01/20] perf: load only the data each asset chart type draws On an asset with charge points, the Default chart and the Charge Points chart shared one chart data request, which loaded every sensor in sensors_to_show. The Default chart skips the "Charge Point sessions" graph, so its session sensors were loaded and thrown away, and the Charge Points chart likewise loaded everything to draw a few graphs. GET /assets//chart_data now accepts chart-type and returns only the data of the sensors that chart type draws (GenericAsset.chart_sensors). Without it, it returns every sensor's data, as before. The asset page sends the selected chart type, and loads that chart's data when the user switches charts; the chart data cache no longer reuses records loaded for another chart type. Closes #2789. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ahmad-Wahid --- documentation/api/change_log.rst | 1 + documentation/changelog.rst | 1 + flexmeasures/api/v3_0/assets.py | 11 +- .../api/v3_0/tests/test_asset_chart_data.py | 197 ++++++++++++++++++ .../data/models/charts/belief_charts.py | 33 +-- flexmeasures/data/models/generic_assets.py | 52 +++++ flexmeasures/ui/static/js/chart-data-cache.js | 20 +- flexmeasures/ui/static/js/chart-data-fetch.js | 6 +- flexmeasures/ui/static/openapi-specs.json | 9 + .../ui/templates/includes/graphs.html | 22 +- .../ui/tests/js/test_chart_data_cache.py | 19 ++ 11 files changed, 347 insertions(+), 24 deletions(-) create mode 100644 flexmeasures/api/v3_0/tests/test_asset_chart_data.py diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 378806dfcf..251f0828fe 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -21,6 +21,7 @@ v3.0-40 | October XX, 2026 - Data sources returned by ``GET /api/v3_0/sources`` and ``GET /api/v3_0/sources/`` now carry the ``account_id`` of the organisation they record for, where only sources of type ``user`` used to. Source filters that select by organisation, such as ``source-account`` on ``GET /api/v3_0/sensors//data`` and inside sensor references in a flex-model or flex-context, therefore match data computed by forecasters, schedulers and reporters, where they used to match user-recorded data only. A source belonging to no organisation is one the host runs for everyone. - ``POST /api/v3_0/assets//automations`` now also refuses a ``source`` which is not the caller's to work with for automation types registered by a plugin, where the refusal used to cover the built-in types only. - ``GET /api/v3_0/sensors//status`` now returns ``missing-members`` with the status of reporter data whose most recent aggregate of a device group, old enough to count as stale, had to leave out members, because they did not report: the ``id``, ``name`` and ``asset-name`` of each of those sensors. The field is left out where no member is missing, as for most sensors. +- ``GET /api/v3_0/assets//chart_data`` now accepts ``chart-type``, as ``GET /api/v3_0/assets//chart`` does, and then returns only the data that chart type draws. For ``chart_for_multiple_sensors``, that leaves out the sensors which appear only in a graph titled "Charge Point sessions"; for ``chart_for_chargepoint_sessions``, it is the charging-session sensors, ``charge points power`` from "Power flow by type", and the "Prices" sensors. Without ``chart-type``, or with another chart type, it returns the data of every sensor in the asset's ``sensors_to_show``, as before. v3.0-39 | September 22, 2026 """""""""""""""""""""""""""" diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 2d9ab7feb2..963b06de6f 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -109,6 +109,7 @@ Infrastructure / Support * Every kind of data generator (e.g. a ``Forecaster``, ``Scheduler`` or ``Reporter``) now has its results checked and saved in the same way: all of a run's outputs are saved or none of them, and recording on a sensor the generator was not checked against raises ``GeneratorWritesUncheckedSensor`` [see `PR #2689 `_ and `PR #2571 `_] * Stop installing the libraries for passkey, authenticator-app and SMS sign-in, which FlexMeasures does not support, as it ships email-based two-factor authentication only; to enable the ``authenticator`` method anyway, install ``qrcode`` [see `PR #2564 `_] * Add a concepts page on data pipelining, which describes in one place how time-series data is referenced (sensor references, source filters, fallback values), cleaned on the way in and bounded on the way out, and link to it from the feature pages and tutorials [see `PR #2721 `_] +* On an asset with charge points, the Default chart no longer loads the charging-session sensors it doesn't draw, and the Charge Points chart loads only the sensors it draws; switching between them loads that chart's data [see `PR #2790 `_] Bugfixes ----------- diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 10736f20ec..d192ae0330 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -89,7 +89,6 @@ GenericAssetSchema as AssetSchema, GenericAssetIdField as AssetIdField, GenericAssetTypeSchema as AssetTypeSchema, - SensorsToShowSchema, ) from flexmeasures.data.schemas.scheduling import AssetTriggerSchema from flexmeasures.data.schemas.utils import SupportsLegacyFieldAliases @@ -257,6 +256,7 @@ class AssetChartDataKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): "use_latest_version_per_event": "use-latest-version-per-event", "most_recent_beliefs_only": "most-recent-beliefs-only", "compress_json": "compress-json", + "chart_type": "chart-type", } use_latest_version_per_event = fields.Boolean( @@ -281,6 +281,13 @@ class AssetChartDataKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): description="If true, compress the JSON response.", ), ) + chart_type = fields.Str( + data_key="chart-type", + required=False, + metadata=dict( + description="Return only the data that this chart type draws, e.g. 'chart_for_multiple_sensors' or 'chart_for_chargepoint_sessions'; without it, return the data of every sensor in the asset's sensors_to_show.", + ), + ) class AssetChartAnnotationsKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): @@ -1234,7 +1241,7 @@ def get_chart_data(self, id: int, asset: GenericAsset, **kwargs): tags: - Assets """ - sensors = SensorsToShowSchema.flatten(asset.validate_sensors_to_show()) + sensors = asset.chart_sensors(kwargs.pop("chart_type", None)) return asset.chart_data_json(sensors=sensors, **kwargs) @route("//chart_annotations", strict_slashes=False) diff --git a/flexmeasures/api/v3_0/tests/test_asset_chart_data.py b/flexmeasures/api/v3_0/tests/test_asset_chart_data.py new file mode 100644 index 0000000000..ba74c87a9e --- /dev/null +++ b/flexmeasures/api/v3_0/tests/test_asset_chart_data.py @@ -0,0 +1,197 @@ +"""Tests for which sensors an asset's chart data holds, per chart type (issue #2789). + +The default chart leaves the graph of charging-session timestamps to the Charge Points chart, +so loading those sensors for it only fetches data that is thrown away. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +import pytest +import pytz +from flask import url_for + +from flexmeasures import Sensor +from flexmeasures.data.models.data_sources import DataSource +from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType +from flexmeasures.data.models.time_series import TimedBelief + +START = datetime(2015, 1, 1, tzinfo=pytz.utc) + + +@pytest.fixture(scope="module") +def charge_hub(db) -> dict[str, Sensor]: + """A public site whose dashboard has the graphs a site with charge points gets. + + One belief per sensor, so the chart data shows which sensors were loaded. + The departure sensor is also in a graph of its own, + to check that the default chart keeps a session sensor it draws elsewhere. + """ + site_type = GenericAssetType(name="test_site_type_for_chart_sensors") + db.session.add(site_type) + site = GenericAsset( + name="Test charge hub (chart sensors)", generic_asset_type=site_type + ) + db.session.add(site) + db.session.flush() + + units = { + "charge points power": "kW", + "building power": "kW", + "consumption price": "EUR/MWh", + "temperature": "°C", + "arrival": "s", + "departure": "s", + "plug in": "s", + } + sensors = { + name: Sensor( + name=name, + unit=unit, + event_resolution=timedelta(minutes=15), + generic_asset=site, + ) + for name, unit in units.items() + } + db.session.add_all(sensors.values()) + db.session.flush() + + site.sensors_to_show = [ + { + "title": "Power flow by type", + "sensors": [ + sensors["charge points power"].id, + sensors["building power"].id, + ], + }, + {"title": "Prices", "sensors": [sensors["consumption price"].id]}, + {"title": "Weather", "sensors": [sensors["temperature"].id]}, + {"title": "Departures", "sensors": [sensors["departure"].id]}, + { + "title": "Charge Point sessions", + "sensors": [ + sensors["arrival"].id, + sensors["departure"].id, + sensors["plug in"].id, + ], + }, + ] + + source = DataSource(name="test script", type="demo script") + db.session.add(source) + db.session.flush() + for sensor in sensors.values(): + db.session.add( + TimedBelief( + sensor=sensor, + event_start=START, + event_value=1.0, + belief_horizon=timedelta(0), + source=source, + ) + ) + db.session.commit() + return sensors + + +def _names(sensors: list[Sensor]) -> list[str]: + return [sensor.name for sensor in sensors] + + +def test_without_a_chart_type_all_sensors_are_loaded(charge_hub): + site = charge_hub["arrival"].generic_asset + assert _names(site.chart_sensors()) == [ + "charge points power", + "building power", + "consumption price", + "temperature", + "departure", + "arrival", + "plug in", + ] + + +def test_default_chart_skips_the_charge_point_sessions_graph(charge_hub): + site = charge_hub["arrival"].generic_asset + assert _names(site.chart_sensors("chart_for_multiple_sensors")) == [ + "charge points power", + "building power", + "consumption price", + "temperature", + # Also in a graph of its own, which the default chart draws. + "departure", + ] + + +def test_charge_points_chart_loads_its_sessions_and_two_companion_graphs( + charge_hub, +): + site = charge_hub["arrival"].generic_asset + assert _names(site.chart_sensors("chart_for_chargepoint_sessions")) == [ + "departure", + "arrival", + "plug in", + "charge points power", + "consumption price", + ] + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +@pytest.mark.parametrize( + "chart_type, expected_names", + [ + ( + None, + { + "charge points power", + "building power", + "consumption price", + "temperature", + "arrival", + "departure", + "plug in", + }, + ), + ( + "chart_for_multiple_sensors", + { + "charge points power", + "building power", + "consumption price", + "temperature", + "departure", + }, + ), + ( + "chart_for_chargepoint_sessions", + { + "charge points power", + "consumption price", + "arrival", + "departure", + "plug in", + }, + ), + ], +) +def test_chart_data_holds_only_what_the_chart_type_draws( + client, setup_roles_users, charge_hub, requesting_user, chart_type, expected_names +): + site = charge_hub["arrival"].generic_asset + query = { + "event_starts_after": START.isoformat(), + "event_ends_before": (START + timedelta(hours=1)).isoformat(), + "compress_json": "true", + } + if chart_type is not None: + query["chart-type"] = chart_type + response = client.get( + url_for("AssetAPI:get_chart_data", id=site.id), query_string=query + ) + assert response.status_code == 200, response.data + # The endpoint sends the JSON as a string, so it is not parsed for us. + loaded_ids = {record["sid"] for record in json.loads(response.data)["data"]} + id_by_name = {name: sensor.id for name, sensor in charge_hub.items()} + assert loaded_ids == {id_by_name[name] for name in expected_names} diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index fb1cff0449..d2c0e37c60 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -17,6 +17,20 @@ from flexmeasures.utils.unit_utils import find_smallest_common_unit, get_unit_dimension from flexmeasures.utils.coding_utils import flatten_unique +# The graph of charging-session timestamps, which only the Charge Points chart draws, +# together with the two graphs it shows alongside them (see chart_for_chargepoint_sessions). +CHARGE_POINT_SESSIONS_TITLE = "Charge Point sessions" +CHARGE_POINT_SESSION_SENSOR_NAMES = ( + "arrival", + "departure", + "start charging", + "stop charging", + "plug in", + "plug out", +) +CHARGE_POINT_COMPANION_TITLES = ("Prices", "Power flow by type") +CHARGE_POINT_POWER_SENSOR_NAME = "charge points power" + def create_bar_chart_or_histogram_specs( sensor: "Sensor", # noqa F821 @@ -769,7 +783,7 @@ def _process_sensor_entry( Sensor specification dictionary or None if entry should be skipped """ title = entry.get("title") - if title == "Charge Point sessions": + if title == CHARGE_POINT_SESSIONS_TITLE: return None sensors = _extract_sensors_from_entry(entry) @@ -1163,23 +1177,14 @@ def chart_for_chargepoint_sessions( [ s for s in sensors - if s.unit == "s" - and s.name - in [ - "arrival", - "departure", - "start charging", - "stop charging", - "plug in", - "plug out", - ] + if s.unit == "s" and s.name in CHARGE_POINT_SESSION_SENSOR_NAMES ] ) sensor_ids = [s.id for s in all_sensors] cp_chart = { - "title": "Charge Point sessions", + "title": CHARGE_POINT_SESSIONS_TITLE, "width": "container", "height": 300, "selection": { @@ -1459,7 +1464,7 @@ def chart_for_chargepoint_sessions( plot["sensors"] = [ sensor for sensor in plot["sensors"] - if sensor.name == "charge points power" + if sensor.name == CHARGE_POINT_POWER_SENSOR_NAME ] chart_specs = chart_for_multiple_sensors( sensors_to_show_copy, @@ -1471,7 +1476,7 @@ def chart_for_chargepoint_sessions( chart_specs["vconcat"] = [ chart for chart in chart_specs["vconcat"] - if chart["title"] in ["Prices", "Power flow by type"] + if chart["title"] in CHARGE_POINT_COMPANION_TITLES ] # Synchronize x-axis panning/zooming across all stacked subcharts. diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 0acdf4d553..9d0d74ede9 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -18,6 +18,11 @@ from flexmeasures.data.models.legacy_migration_utils import upgrade_value from flexmeasures.data.models.annotations import Annotation, to_annotation_frame from flexmeasures.data.models.charts import chart_type_to_chart_specs +from flexmeasures.data.models.charts.belief_charts import ( + CHARGE_POINT_POWER_SENSOR_NAME, + CHARGE_POINT_SESSION_SENSOR_NAMES, + CHARGE_POINT_SESSIONS_TITLE, +) from flexmeasures.data.models.data_sources import DataSource from flexmeasures.data.models.parsing_utils import parse_source_arg from flexmeasures.data.models.user import User @@ -988,6 +993,53 @@ def count_annotations( ) return db.session.scalar(select(func.count()).select_from(query.subquery())) + def chart_sensors( + self, chart_type: str | None = None + ) -> list["Sensor"]: # noqa F821 + """The sensors in this asset's sensors_to_show whose data the given chart type draws. + + The default chart leaves the graph of charging-session timestamps to the Charge Points chart, + and the Charge Points chart draws only those timestamps and its two companion graphs; + loading the rest would be loading data that is thrown away. + Any other chart type, or none, gets all sensors. + + :param chart_type: "chart_for_multiple_sensors", "chart_for_chargepoint_sessions" or None + :returns: a unique list of sensors, in the order of sensors_to_show + """ + from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema + + sensors_to_show = self.validate_sensors_to_show() + + def sensors_in_graph(title: str) -> list["Sensor"]: # noqa F821 + return SensorsToShowSchema.flatten( + [entry for entry in sensors_to_show if entry.get("title") == title] + ) + + if chart_type == "chart_for_multiple_sensors": + # A sensor that is also in another graph is still drawn, so it stays. + return SensorsToShowSchema.flatten( + [ + entry + for entry in sensors_to_show + if entry.get("title") != CHARGE_POINT_SESSIONS_TITLE + ] + ) + if chart_type == "chart_for_chargepoint_sessions": + session_sensors = [ + sensor + for sensor in SensorsToShowSchema.flatten(sensors_to_show) + if getattr(sensor, "unit", None) == "s" + and sensor.name in CHARGE_POINT_SESSION_SENSOR_NAMES + ] + power_sensors = [ + sensor + for sensor in sensors_in_graph("Power flow by type") + if sensor.name == CHARGE_POINT_POWER_SENSOR_NAME + ] + price_sensors = sensors_in_graph("Prices") + return list(dict.fromkeys(session_sensors + power_sensors + price_sensors)) + return SensorsToShowSchema.flatten(sensors_to_show) + def chart( self, chart_type: str = "chart_for_multiple_sensors", diff --git a/flexmeasures/ui/static/js/chart-data-cache.js b/flexmeasures/ui/static/js/chart-data-cache.js index 46d9b723fa..524a9e1230 100644 --- a/flexmeasures/ui/static/js/chart-data-cache.js +++ b/flexmeasures/ui/static/js/chart-data-cache.js @@ -178,18 +178,24 @@ export function createChartDataCache({ maxAgeMs = DEFAULT_MAX_AGE_MS } = {}) { * @param {Date} options.start - Start of the newly selected window. * @param {Date} options.end - End of the newly selected window. * @param {boolean} [options.mostRecentBeliefsOnly] - Pass false to get every recorded belief. + * @param {string} [options.chartType] - On an asset, get only the data this chart type draws. * @param {AbortSignal} [options.signal] - Signal used to abort in-flight requests. * @returns {Promise} - Belief records covering exactly the newly selected window. */ async load(dataPath, options) { - const { start, end, mostRecentBeliefsOnly, signal } = options; + const { start, end, mostRecentBeliefsOnly, chartType, signal } = options; // Work from a snapshot throughout. // Selections are not serialised and their requests are not aborted, // so a second selection can replace what is held while this one is still awaiting its data. // Merging against the snapshot keeps each result's span and records describing the same thing. // Records past their age are treated as absent, so this window is fetched afresh. + // Records loaded for another chart type cover other sensors, so they are treated as absent too. const held = - cached !== null && Date.now() - cached.fetchedAt < maxAgeMs ? cached : null; + cached !== null && + Date.now() - cached.fetchedAt < maxAgeMs && + cached.chartType === chartType + ? cached + : null; const resolutionMs = held ? effectiveResolutionMs(held.data) : 0; // Reuse only what a direct fetch would have returned identically. // Windows that merely touch still form one contiguous span, @@ -210,6 +216,7 @@ export function createChartDataCache({ maxAgeMs = DEFAULT_MAX_AGE_MS } = {}) { start: range.start, end: range.end, mostRecentBeliefsOnly: mostRecentBeliefsOnly, + chartType: chartType, signal: signal, }) ) @@ -221,8 +228,15 @@ export function createChartDataCache({ maxAgeMs = DEFAULT_MAX_AGE_MS } = {}) { data: dedupeRecords(held.data.concat(...fetched)), // Extending keeps the original age, so a span cannot outlive the limit by being added to. fetchedAt: held.fetchedAt, + chartType: chartType, } - : { start: start, end: end, data: [].concat(...fetched), fetchedAt: Date.now() }; + : { + start: start, + end: end, + data: [].concat(...fetched), + fetchedAt: Date.now(), + chartType: chartType, + }; // Whichever selection finishes last decides what is held. // Both spans are self-consistent, so the worst case is a later selection re-fetching. cached = assembled; diff --git a/flexmeasures/ui/static/js/chart-data-fetch.js b/flexmeasures/ui/static/js/chart-data-fetch.js index fb0c25b5ae..89312a9aaa 100644 --- a/flexmeasures/ui/static/js/chart-data-fetch.js +++ b/flexmeasures/ui/static/js/chart-data-fetch.js @@ -20,9 +20,10 @@ import { decompressChartData } from "./chart-data-utils.js"; * @param {Date} options.start - Start of the event window (inclusive). * @param {Date} options.end - End of the event window (exclusive). * @param {boolean} [options.mostRecentBeliefsOnly] - Pass false to get every recorded belief, as the replay does. + * @param {string} [options.chartType] - On an asset, get only the data this chart type draws. * @returns {string} - The URL to fetch. */ -export function buildChartDataUrl(dataPath, { start, end, mostRecentBeliefsOnly }) { +export function buildChartDataUrl(dataPath, { start, end, mostRecentBeliefsOnly, chartType }) { let url = dataPath + "/chart_data?event_starts_after=" + @@ -32,6 +33,9 @@ export function buildChartDataUrl(dataPath, { start, end, mostRecentBeliefsOnly if (mostRecentBeliefsOnly === false) { url += "&most_recent_beliefs_only=false"; } + if (chartType) { + url += "&chart-type=" + encodeURIComponent(chartType); + } return url + "&compress_json=true"; } diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 35621ee5f5..aea16d0714 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4258,6 +4258,15 @@ "type": "boolean" }, "required": false + }, + { + "in": "query", + "name": "chart-type", + "description": "Return only the data that this chart type draws, e.g. 'chart_for_multiple_sensors' or 'chart_for_chargepoint_sessions'; without it, return the data of every sensor in the asset's sensors_to_show.", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 73840cbe30..4de1f55708 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -87,6 +87,13 @@ chartType = 'chart_for_multiple_sensors'; } + // On an asset, each chart type loads only the data it draws (see GenericAsset.chart_sensors), + // so the Default chart skips the charge point sessions that only the Charge Points chart shows. + // The sensor page's chart types all draw the one sensor, so its requests name none. + function chartDataChartType() { + return ('{{ active_page }}' === 'assets') ? chartType : undefined; + } + // Initialize Jinja variables {% if active_page == "assets" %} dataPath = '/api/v3_0/assets/' + {{ asset.id }}; @@ -455,6 +462,13 @@ item.classList.remove('active'); } }); + {% if active_page == "assets" %} + // The data held was loaded for the previous chart type, which draws other sensors, + // so this one loads its own (reloadChartData renders it, in either renderer). + playBackDataLoadedForKnownDateRange = false; + reloadChartData(); + return; + {% endif %} // Switch to the right renderer for the newly selected chart type: the // fast chart if fast mode is on and this type supports it (updateFastChart), // otherwise Vega-Lite (some chart types, like Charge Point sessions, are @@ -714,7 +728,7 @@ // and claims a count of its own, so that a selection made while it runs supersedes it in turn. const thisReload = ++shownWindowGeneration; const reloaded = await Promise.all([ - chartDataCache.load(dataPath, { start: storeStartDate, end: storeEndDate, signal: signal }), + chartDataCache.load(dataPath, { start: storeStartDate, end: storeEndDate, chartType: chartDataChartType(), signal: signal }), fetchChartAnnotations(dataPath, { start: storeStartDate, end: storeEndDate, signal: signal }), embedAndLoad(chartSpecsPath + 'event_starts_after=' + storeStartDate.toISOString() + '&event_ends_before=' + storeEndDate.toISOString() + '&', elementId, datasetName, previousResult, storeStartDate, storeEndDate), ]).catch(console.error); @@ -781,7 +795,7 @@ // The picker is already on the page, so a range can be selected while this first load is still running. const thisInitialLoad = ++shownWindowGeneration; // Through the cache, so that the very first date change already only fetches the days it adds. - const initialData = chartDataCache.load(dataPath, { start: storeStartDate, end: storeEndDate, signal: signal }); + const initialData = chartDataCache.load(dataPath, { start: storeStartDate, end: storeEndDate, chartType: chartDataChartType(), signal: signal }); const initialResult = await Promise.all([ initialData, fetchChartAnnotations(dataPath, { start: storeStartDate, end: storeEndDate, signal: signal }), @@ -1193,7 +1207,7 @@ // Deliberately not through chartDataCache: that holds only the most recent belief per event, // so mixing every belief into it would answer later chart requests with data the chart does not draw. // Replay keeps its own copy in previousPlaybackResult instead. - fetchChartData(dataPath, { start: storeStartDate, end: storeEndDate, mostRecentBeliefsOnly: false, signal: signal }), + fetchChartData(dataPath, { start: storeStartDate, end: storeEndDate, mostRecentBeliefsOnly: false, chartType: chartDataChartType(), signal: signal }), ]).then(function (result) { previousPlaybackResult = result[0]; replayBeliefsData(result[0]); @@ -1285,7 +1299,7 @@ getAssetKPIs(new Date(startDate), new Date(endDate)); {% endif %} // Only the days not already held are fetched (issue #101). - return chartDataCache.load(dataPath, { start: startDate, end: endDate, signal: signal }); + return chartDataCache.load(dataPath, { start: startDate, end: endDate, chartType: chartDataChartType(), signal: signal }); } } diff --git a/flexmeasures/ui/tests/js/test_chart_data_cache.py b/flexmeasures/ui/tests/js/test_chart_data_cache.py index fdb88cc387..5dfde6727a 100644 --- a/flexmeasures/ui/tests/js/test_chart_data_cache.py +++ b/flexmeasures/ui/tests/js/test_chart_data_cache.py @@ -126,6 +126,25 @@ def test_build_chart_data_url(assert_js): buildChartDataUrl("/x", {start, end, mostRecentBeliefsOnly: false}).includes("most_recent_beliefs_only=false")); check("the default leaves it to the API", !buildChartDataUrl("/x", {start, end, mostRecentBeliefsOnly: true}).includes("most_recent_beliefs_only")); + check("a chart type asks for only the data that chart draws", + buildChartDataUrl("/x", {start, end, chartType: "chart_for_chargepoint_sessions"}) + .includes("&chart-type=chart_for_chargepoint_sessions")); + check("without one, the API returns every sensor's data", + !buildChartDataUrl("/x", {start, end}).includes("chart-type")); + """) + + +def test_cache_does_not_reuse_records_loaded_for_another_chart_type(assert_js): + """The asset charts load different sensors per chart type (issue #2789).""" + assert_js(FAKE_API + """ + import { createChartDataCache } from "/js/chart-data-cache.js"; + const cache = createChartDataCache(); + await cache.load("/a", {start: day(10), end: day(17), chartType: "chart_for_multiple_sensors"}); + await cache.load("/a", {start: day(11), end: day(12), chartType: "chart_for_multiple_sensors"}); + eq("the same chart type is answered from memory", requests.length, 1); + await cache.load("/a", {start: day(11), end: day(12), chartType: "chart_for_chargepoint_sessions"}); + eq("another chart type fetches its own data", requests.length, 2); + check("naming that chart type", requests[1].includes("chart-type=chart_for_chargepoint_sessions"), requests[1]); """) From 20fc90eb3d811872b6f903a13bf6b6ca8ea16d7f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Sat, 31 Oct 2026 22:25:59 +0100 Subject: [PATCH 02/20] Stop a running replay before switching charts, and name the companion graph titles Switching between the asset charts reloaded the data without stopping a replay, which would keep drawing the previous chart type's data; and stopping a replay redraws the Vega-Lite chart, so the reload now waits for it. The "Prices" and "Power flow by type" titles were spelled out again in GenericAsset.chart_sensors; both now come from belief_charts.py. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ahmad-Wahid --- flexmeasures/data/models/charts/belief_charts.py | 7 +++++-- flexmeasures/data/models/generic_assets.py | 6 ++++-- flexmeasures/ui/templates/includes/graphs.html | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index d2c0e37c60..de16831951 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -28,7 +28,10 @@ "plug in", "plug out", ) -CHARGE_POINT_COMPANION_TITLES = ("Prices", "Power flow by type") +PRICES_TITLE = "Prices" +POWER_FLOW_BY_TYPE_TITLE = "Power flow by type" +CHARGE_POINT_COMPANION_TITLES = (PRICES_TITLE, POWER_FLOW_BY_TYPE_TITLE) +# Of the power flows, the Charge Points chart shows only this one. CHARGE_POINT_POWER_SENSOR_NAME = "charge points power" @@ -1458,7 +1461,7 @@ def chart_for_chargepoint_sessions( } for idx, entry in enumerate(sensors_to_show_copy): title = entry.get("title") - if title == "Power flow by type": + if title == POWER_FLOW_BY_TYPE_TITLE: for plot in entry.get("plots", []): if "sensors" in plot: plot["sensors"] = [ diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 9d0d74ede9..e54ef5cb88 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -22,6 +22,8 @@ CHARGE_POINT_POWER_SENSOR_NAME, CHARGE_POINT_SESSION_SENSOR_NAMES, CHARGE_POINT_SESSIONS_TITLE, + POWER_FLOW_BY_TYPE_TITLE, + PRICES_TITLE, ) from flexmeasures.data.models.data_sources import DataSource from flexmeasures.data.models.parsing_utils import parse_source_arg @@ -1033,10 +1035,10 @@ def sensors_in_graph(title: str) -> list["Sensor"]: # noqa F821 ] power_sensors = [ sensor - for sensor in sensors_in_graph("Power flow by type") + for sensor in sensors_in_graph(POWER_FLOW_BY_TYPE_TITLE) if sensor.name == CHARGE_POINT_POWER_SENSOR_NAME ] - price_sensors = sensors_in_graph("Prices") + price_sensors = sensors_in_graph(PRICES_TITLE) return list(dict.fromkeys(session_sensors + power_sensors + price_sensors)) return SensorsToShowSchema.flatten(sensors_to_show) diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 4de1f55708..04f845074c 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -465,8 +465,10 @@ {% if active_page == "assets" %} // The data held was loaded for the previous chart type, which draws other sensors, // so this one loads its own (reloadChartData renders it, in either renderer). + // A running replay is stopped first: it would keep drawing the previous chart type's data, + // and stopping it redraws the Vega-Lite chart, which must not land after the reload. playBackDataLoadedForKnownDateRange = false; - reloadChartData(); + stopReplay().catch(console.error).then(() => reloadChartData()); return; {% endif %} // Switch to the right renderer for the newly selected chart type: the From 0b6bf8b25f6b11085d3fec34e080068bd8a93dc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:55:55 +0000 Subject: [PATCH 03/20] api/v3_0/tests: isolate asset chart data tests with fresh_db Co-authored-by: Ahmad-Wahid <59763365+Ahmad-Wahid@users.noreply.github.com> --- .../api/v3_0/tests/test_asset_chart_data.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_asset_chart_data.py b/flexmeasures/api/v3_0/tests/test_asset_chart_data.py index ba74c87a9e..7804867f46 100644 --- a/flexmeasures/api/v3_0/tests/test_asset_chart_data.py +++ b/flexmeasures/api/v3_0/tests/test_asset_chart_data.py @@ -21,8 +21,8 @@ START = datetime(2015, 1, 1, tzinfo=pytz.utc) -@pytest.fixture(scope="module") -def charge_hub(db) -> dict[str, Sensor]: +@pytest.fixture +def charge_hub(fresh_db) -> dict[str, Sensor]: """A public site whose dashboard has the graphs a site with charge points gets. One belief per sensor, so the chart data shows which sensors were loaded. @@ -30,12 +30,12 @@ def charge_hub(db) -> dict[str, Sensor]: to check that the default chart keeps a session sensor it draws elsewhere. """ site_type = GenericAssetType(name="test_site_type_for_chart_sensors") - db.session.add(site_type) + fresh_db.session.add(site_type) site = GenericAsset( name="Test charge hub (chart sensors)", generic_asset_type=site_type ) - db.session.add(site) - db.session.flush() + fresh_db.session.add(site) + fresh_db.session.flush() units = { "charge points power": "kW", @@ -55,8 +55,8 @@ def charge_hub(db) -> dict[str, Sensor]: ) for name, unit in units.items() } - db.session.add_all(sensors.values()) - db.session.flush() + fresh_db.session.add_all(sensors.values()) + fresh_db.session.flush() site.sensors_to_show = [ { @@ -80,10 +80,10 @@ def charge_hub(db) -> dict[str, Sensor]: ] source = DataSource(name="test script", type="demo script") - db.session.add(source) - db.session.flush() + fresh_db.session.add(source) + fresh_db.session.flush() for sensor in sensors.values(): - db.session.add( + fresh_db.session.add( TimedBelief( sensor=sensor, event_start=START, @@ -92,7 +92,7 @@ def charge_hub(db) -> dict[str, Sensor]: source=source, ) ) - db.session.commit() + fresh_db.session.commit() return sensors @@ -177,7 +177,12 @@ def test_charge_points_chart_loads_its_sessions_and_two_companion_graphs( ], ) def test_chart_data_holds_only_what_the_chart_type_draws( - client, setup_roles_users, charge_hub, requesting_user, chart_type, expected_names + client, + setup_roles_users_fresh_db, + charge_hub, + requesting_user, + chart_type, + expected_names, ): site = charge_hub["arrival"].generic_asset query = { From 9974e131f63e3a8b18ecd77740dd46e210c7a3ac Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 13:37:55 +0100 Subject: [PATCH 04/20] Load the companion graphs the Charge Points chart draws, whatever the case of their titles The Vega-Lite Charge Points chart capitalizes graph titles before matching them, but chart_sensors compared the raw titles, so a graph titled "prices" or "power flow by type" was drawn without its data. One helper in belief_charts.py now decides which sensors of a graph the chart draws, used by both chart_for_chargepoint_sessions and chart_sensors. The power-flow filter now also matches the capitalized title, and no longer mutates the caller's sensors_to_show. The fast chart matches companion titles the same way. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- .../api/v3_0/tests/test_asset_chart_data.py | 51 ++++++++++ .../data/models/charts/belief_charts.py | 95 +++++++++++++++---- flexmeasures/data/models/generic_assets.py | 24 +---- flexmeasures/ui/static/js/fast-chart.js | 22 ++++- .../ui/templates/includes/graphs.html | 6 +- flexmeasures/ui/tests/js/test_fast_chart.py | 15 +++ 6 files changed, 163 insertions(+), 50 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_asset_chart_data.py b/flexmeasures/api/v3_0/tests/test_asset_chart_data.py index 7804867f46..61e77f337f 100644 --- a/flexmeasures/api/v3_0/tests/test_asset_chart_data.py +++ b/flexmeasures/api/v3_0/tests/test_asset_chart_data.py @@ -138,6 +138,57 @@ def test_charge_points_chart_loads_its_sessions_and_two_companion_graphs( ] +def _sensor_ids_drawn(chart_specs: dict | list) -> set[int]: + """The ids of the sensors that the filters in these chart specs let through.""" + ids: set[int] = set() + if isinstance(chart_specs, dict): + sensor_filter = chart_specs.get("filter") + if ( + isinstance(sensor_filter, dict) + and sensor_filter.get("field") == "sensor.id" + ): + ids.update(sensor_filter["oneOf"]) + for value in chart_specs.values(): + ids |= _sensor_ids_drawn(value) + elif isinstance(chart_specs, list): + for value in chart_specs: + ids |= _sensor_ids_drawn(value) + return ids + + +@pytest.mark.parametrize( + "prices_title, power_flow_title", + [ + ("Prices", "Power flow by type"), + ("prices", "Power flow by type"), + ("Prices", "power flow by type"), + ("prices", "power flow by type"), + ], +) +def test_charge_points_chart_loads_what_it_draws_whatever_the_title_case( + fresh_db, charge_hub, prices_title, power_flow_title +): + """The Charge Points chart capitalizes graph titles, so a lowercase companion title still counts.""" + site = charge_hub["arrival"].generic_asset + titles = {"Prices": prices_title, "Power flow by type": power_flow_title} + site.sensors_to_show = [ + {**entry, "title": titles.get(entry["title"], entry["title"])} + for entry in site.sensors_to_show + ] + fresh_db.session.commit() + + loaded = site.chart_sensors("chart_for_chargepoint_sessions") + assert _names(loaded) == [ + "departure", + "arrival", + "plug in", + "charge points power", + "consumption price", + ] + chart_specs = site.chart(chart_type="chart_for_chargepoint_sessions") + assert _sensor_ids_drawn(chart_specs) == {sensor.id for sensor in loaded} + + @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) @pytest.mark.parametrize( "chart_type, expected_names", diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index de16831951..b1de229651 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -35,6 +35,74 @@ CHARGE_POINT_POWER_SENSOR_NAME = "charge points power" +def _charge_point_session_sensors( + sensors_to_show: list[dict], +) -> list["Sensor"]: # noqa F821 + """The sensors in any graph that record charging-session timestamps, which the Charge Points chart draws as sessions.""" + return list( + dict.fromkeys( + sensor + for entry in sensors_to_show + for sensor in _extract_sensors_from_entry(entry) + if getattr(sensor, "unit", None) == "s" + and sensor.name in CHARGE_POINT_SESSION_SENSOR_NAMES + ) + ) + + +def charge_point_companion_graph(entry: dict) -> dict | None: + """The part of this sensors_to_show graph that the Charge Points chart draws below its sessions, if any. + + Graph titles are matched as the chart shows them, i.e. capitalized, + so a graph titled "prices" counts as a Prices graph. + Of a Power flow by type graph, only the charge points power is kept. + + :param entry: a graph of a validated sensors_to_show. + :returns: the graph (for Power flow by type, a copy holding only the charge points power), or None if the chart does not draw it. + """ + title = entry.get("title") + if not title or capitalize(title) not in CHARGE_POINT_COMPANION_TITLES: + return None + if capitalize(title) != POWER_FLOW_BY_TYPE_TITLE: + return entry + plots = [] + for plot in entry.get("plots", []): + if "sensors" in plot: + plot = { + **plot, + "sensors": [ + sensor + for sensor in plot["sensors"] + if sensor.name == CHARGE_POINT_POWER_SENSOR_NAME + ], + } + elif "sensor" in plot and plot["sensor"].name != CHARGE_POINT_POWER_SENSOR_NAME: + continue + plots.append(plot) + return {**entry, "plots": plots} + + +def charge_point_chart_sensors( + sensors_to_show: list[dict], +) -> list["Sensor"]: # noqa F821 + """The sensors whose data the Charge Points chart draws. + + These are the session timestamps in any graph, + followed by the sensors of the companion graphs it draws below them (see charge_point_companion_graph). + + :param sensors_to_show: a validated sensors_to_show. + :returns: a unique list of sensors. + """ + session_sensors = _charge_point_session_sensors(sensors_to_show) + companion_sensors = [ + sensor + for entry in sensors_to_show + if (graph := charge_point_companion_graph(entry)) is not None + for sensor in _extract_sensors_from_entry(graph) + ] + return list(dict.fromkeys(session_sensors + companion_sensors)) + + def create_bar_chart_or_histogram_specs( sensor: "Sensor", # noqa F821 event_starts_after: datetime | None = None, @@ -1172,17 +1240,7 @@ def chart_for_chargepoint_sessions( **override_chart_specs, ) -> dict: - all_sensors = [] - sensors_to_show_copy = sensors_to_show.copy() - for entry in sensors_to_show_copy: - sensors = _extract_sensors_from_entry(entry) - all_sensors.extend( - [ - s - for s in sensors - if s.unit == "s" and s.name in CHARGE_POINT_SESSION_SENSOR_NAMES - ] - ) + all_sensors = _charge_point_session_sensors(sensors_to_show) sensor_ids = [s.id for s in all_sensors] @@ -1459,16 +1517,11 @@ def chart_for_chargepoint_sessions( }, ], } - for idx, entry in enumerate(sensors_to_show_copy): - title = entry.get("title") - if title == POWER_FLOW_BY_TYPE_TITLE: - for plot in entry.get("plots", []): - if "sensors" in plot: - plot["sensors"] = [ - sensor - for sensor in plot["sensors"] - if sensor.name == CHARGE_POINT_POWER_SENSOR_NAME - ] + # The companion graphs hold only what is drawn (see charge_point_companion_graph); + # the other graphs are built too, but dropped from the vconcat below. + sensors_to_show_copy = [ + charge_point_companion_graph(entry) or entry for entry in sensors_to_show + ] chart_specs = chart_for_multiple_sensors( sensors_to_show_copy, event_starts_after, diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index e54ef5cb88..9364d9c5c8 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -19,11 +19,8 @@ from flexmeasures.data.models.annotations import Annotation, to_annotation_frame from flexmeasures.data.models.charts import chart_type_to_chart_specs from flexmeasures.data.models.charts.belief_charts import ( - CHARGE_POINT_POWER_SENSOR_NAME, - CHARGE_POINT_SESSION_SENSOR_NAMES, CHARGE_POINT_SESSIONS_TITLE, - POWER_FLOW_BY_TYPE_TITLE, - PRICES_TITLE, + charge_point_chart_sensors, ) from flexmeasures.data.models.data_sources import DataSource from flexmeasures.data.models.parsing_utils import parse_source_arg @@ -1012,11 +1009,6 @@ def chart_sensors( sensors_to_show = self.validate_sensors_to_show() - def sensors_in_graph(title: str) -> list["Sensor"]: # noqa F821 - return SensorsToShowSchema.flatten( - [entry for entry in sensors_to_show if entry.get("title") == title] - ) - if chart_type == "chart_for_multiple_sensors": # A sensor that is also in another graph is still drawn, so it stays. return SensorsToShowSchema.flatten( @@ -1027,19 +1019,7 @@ def sensors_in_graph(title: str) -> list["Sensor"]: # noqa F821 ] ) if chart_type == "chart_for_chargepoint_sessions": - session_sensors = [ - sensor - for sensor in SensorsToShowSchema.flatten(sensors_to_show) - if getattr(sensor, "unit", None) == "s" - and sensor.name in CHARGE_POINT_SESSION_SENSOR_NAMES - ] - power_sensors = [ - sensor - for sensor in sensors_in_graph(POWER_FLOW_BY_TYPE_TITLE) - if sensor.name == CHARGE_POINT_POWER_SENSOR_NAME - ] - price_sensors = sensors_in_graph(PRICES_TITLE) - return list(dict.fromkeys(session_sensors + power_sensors + price_sensors)) + return charge_point_chart_sensors(sensors_to_show) return SensorsToShowSchema.flatten(sensors_to_show) def chart( diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index b0a2391aa6..e7b3e39f0d 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2153,6 +2153,22 @@ const CHARGEPOINT_POWER_SENSOR_NAME = "charge points power"; // sessions chart type shows them; the default multi-sensor view hides this group. const CHARGE_POINT_SESSIONS_GROUP_TITLE = "Charge Point sessions"; +// Graph titles are matched as the Vega-Lite chart shows them, i.e. with a capital first letter, +// so a graph titled "prices" counts as a Prices graph (see charge_point_companion_graph in belief_charts.py). +function capitalizeTitle(title) { + return title ? title[0].toUpperCase() + title.slice(1) : title; +} + +/** + * Whether the Charge Points chart shows this graph below its sessions. + * + * @param {string} title - The graph's title, as in sensors_to_show. + * @returns {boolean} + */ +export function isChargePointCompanionTitle(title) { + return CHARGEPOINT_COMPANION_TITLES.includes(capitalizeTitle(title)); +} + export function buildChargePointSessionsOption(elementId, data, opts) { const sessions = pivotChargePointSessions(data); if (sessions.length === 0) { @@ -2181,15 +2197,15 @@ export function buildChargePointSessionsOption(elementId, data, opts) { // Same two companion subplots Vega-Lite stacks below the sessions chart, built // via the normal multi-sensor line-chart renderer and spliced in underneath. - const companionSpec = (opts.groupSpec || []).filter((g) => CHARGEPOINT_COMPANION_TITLES.includes(g.title)); + const companionSpec = (opts.groupSpec || []).filter((g) => isChargePointCompanionTitle(g.title)); let companionGroups = companionSpec.length > 0 ? groupData(data, companionSpec) : []; // groupData() falls back to grouping any row not covered by companionSpec's // sensorIds by unit, producing extra ad-hoc groups for the asset's other // sensors (soc, power, ...) — drop everything except the two intended titles. companionGroups = companionGroups - .filter((g) => CHARGEPOINT_COMPANION_TITLES.includes(g.title)) + .filter((g) => isChargePointCompanionTitle(g.title)) .map((g) => - g.title === "Power flow by type" + capitalizeTitle(g.title) === "Power flow by type" ? Object.assign({}, g, { series: g.series.filter((s) => s.sensorName === CHARGEPOINT_POWER_SENSOR_NAME) }) : g ) diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 04f845074c..7c21824d2b 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -30,7 +30,7 @@ import { fetchChartData, fetchChartAnnotations } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-data-fetch.js') }}?v={{ flexmeasures_version }}"; import { restoreChartDateRange, rememberChartDateRange } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-date-range.js') }}?v={{ flexmeasures_version }}"; import { createChartDataCache } from "{{ url_for('flexmeasures_ui.static', filename='js/chart-data-cache.js') }}?v={{ flexmeasures_version }}"; - import { renderFastChart, disposeFastChart, setFastChartReplayTime, setFastChartFullBeliefInfo, getFastChartVisibleTimeRange, refreshFastChartAnnotations, setFastChartSelection } from "{{ url_for('flexmeasures_ui.static', filename='js/fast-chart.js') }}?v={{ flexmeasures_version }}"; + import { renderFastChart, disposeFastChart, setFastChartReplayTime, setFastChartFullBeliefInfo, getFastChartVisibleTimeRange, refreshFastChartAnnotations, setFastChartSelection, isChargePointCompanionTitle } from "{{ url_for('flexmeasures_ui.static', filename='js/fast-chart.js') }}?v={{ flexmeasures_version }}"; // Only assets have a per-sub-chart "y-axis" config to check loaded data against. // Reassigned on save (see reloadChartData) since a save doesn't reload the page. @@ -379,9 +379,7 @@ const beliefTooltipSupported = () => { if ('{{ active_page }}' === 'sensors') { return chartType === 'bar_chart'; } if (chartType !== 'chart_for_chargepoint_sessions') { return true; } - return (fastChartGroupSpec || []).some((group) => - ['Power flow by type', 'Prices'].includes(group.title) - ); + return (fastChartGroupSpec || []).some((group) => isChargePointCompanionTitle(group.title)); }; syncFullBeliefInfoToggle = () => { if (!fullBeliefInfoToggleContainer) { return; } diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index e06680d97b..80fd9bec72 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -556,3 +556,18 @@ def test_replay_ruler_survives_empty_data(assert_js): disposeFastChart(container.id); container.remove(); """) + + +def test_charge_point_companion_titles_match_whatever_the_case_of_their_first_letter( + assert_js, +): + """Like the Vega-Lite chart, the fast chart capitalizes a graph title before matching it.""" + assert_js(""" + import { isChargePointCompanionTitle } from "/js/fast-chart.js"; + for (const title of ["Prices", "prices", "Power flow by type", "power flow by type"]) { + eq(`"${title}" is a companion graph`, isChargePointCompanionTitle(title), true); + } + for (const title of ["Weather", "Charge Point sessions", "PRICES", "", null, undefined]) { + eq(`"${title}" is not a companion graph`, isChargePointCompanionTitle(title), false); + } + """) From 320bfb741fb75ed26ee5a00c8128942ebab9bd82 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 13:38:31 +0100 Subject: [PATCH 05/20] Name the asset chart data test module after its fresh_db fixture Per testing.instructions.md, modules using fresh_db end in _fresh_db.py. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- ...test_asset_chart_data.py => test_asset_chart_data_fresh_db.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename flexmeasures/api/v3_0/tests/{test_asset_chart_data.py => test_asset_chart_data_fresh_db.py} (100%) diff --git a/flexmeasures/api/v3_0/tests/test_asset_chart_data.py b/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py similarity index 100% rename from flexmeasures/api/v3_0/tests/test_asset_chart_data.py rename to flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py From b3a8fe361642312680ed2fdbc0598d9c1f984d6e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 13:39:24 +0100 Subject: [PATCH 06/20] Accept chart-type on chart_data without a snake_case alias The field is new, so no older client sends chart_type, and legacy aliases are for renamed fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/assets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index d192ae0330..ca724ef3e4 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -256,7 +256,6 @@ class AssetChartDataKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): "use_latest_version_per_event": "use-latest-version-per-event", "most_recent_beliefs_only": "most-recent-beliefs-only", "compress_json": "compress-json", - "chart_type": "chart-type", } use_latest_version_per_event = fields.Boolean( From 434978791c9499b977f701fb82868d86d8c04255 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 13:40:08 +0100 Subject: [PATCH 07/20] Give the chart-type field of chart_data an example Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/assets.py | 1 + flexmeasures/ui/static/openapi-specs.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index ca724ef3e4..4395f4e9df 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -285,6 +285,7 @@ class AssetChartDataKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): required=False, metadata=dict( description="Return only the data that this chart type draws, e.g. 'chart_for_multiple_sensors' or 'chart_for_chargepoint_sessions'; without it, return the data of every sensor in the asset's sensors_to_show.", + example="chart_for_multiple_sensors", ), ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index aea16d0714..bc6a4f80ce 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4264,7 +4264,8 @@ "name": "chart-type", "description": "Return only the data that this chart type draws, e.g. 'chart_for_multiple_sensors' or 'chart_for_chargepoint_sessions'; without it, return the data of every sensor in the asset's sensors_to_show.", "schema": { - "type": "string" + "type": "string", + "example": "chart_for_multiple_sensors" }, "required": false } From 399ed9f1f5505c0923075c036c15f19e7af6f7d9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 13:40:34 +0100 Subject: [PATCH 08/20] End the :param and :returns lines of chart_sensors with punctuation Also say that the Charge Points chart lists its session sensors first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/data/models/generic_assets.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 9364d9c5c8..421f174ee0 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -1002,8 +1002,8 @@ def chart_sensors( loading the rest would be loading data that is thrown away. Any other chart type, or none, gets all sensors. - :param chart_type: "chart_for_multiple_sensors", "chart_for_chargepoint_sessions" or None - :returns: a unique list of sensors, in the order of sensors_to_show + :param chart_type: "chart_for_multiple_sensors", "chart_for_chargepoint_sessions" or None. + :returns: a unique list of sensors, in the order of sensors_to_show (for the Charge Points chart, its session sensors first). """ from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema From d9bca9751095199aab360469ace10043305906ae Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 13:42:25 +0100 Subject: [PATCH 09/20] Hand the fast chart the Charge Points chart titles from the Python constants fast-chart.js and graphs.html kept their own copies of the graph titles and the power sensor name. They now get them from CHARGE_POINT_CHART_NAMES in belief_charts.py, through a Jinja global, so renaming one no longer needs edits in three places. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- .../data/models/charts/belief_charts.py | 7 ++++ flexmeasures/ui/__init__.py | 3 ++ flexmeasures/ui/static/js/fast-chart.js | 37 ++++++++++--------- .../ui/templates/includes/graphs.html | 5 ++- flexmeasures/ui/tests/js/test_fast_chart.py | 11 ++++-- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index b1de229651..0f86960407 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -33,6 +33,13 @@ CHARGE_POINT_COMPANION_TITLES = (PRICES_TITLE, POWER_FLOW_BY_TYPE_TITLE) # Of the power flows, the Charge Points chart shows only this one. CHARGE_POINT_POWER_SENSOR_NAME = "charge points power" +# The same names, for the fast chart in the browser (handed to it by includes/graphs.html), so it matches what is drawn here. +CHARGE_POINT_CHART_NAMES = { + "sessionsTitle": CHARGE_POINT_SESSIONS_TITLE, + "companionTitles": list(CHARGE_POINT_COMPANION_TITLES), + "powerFlowTitle": POWER_FLOW_BY_TYPE_TITLE, + "powerSensorName": CHARGE_POINT_POWER_SENSOR_NAME, +} def _charge_point_session_sensors( diff --git a/flexmeasures/ui/__init__.py b/flexmeasures/ui/__init__.py index a8d31003ea..f65de0fc56 100644 --- a/flexmeasures/ui/__init__.py +++ b/flexmeasures/ui/__init__.py @@ -319,6 +319,8 @@ def shared_sensor_type(sensors): def add_jinja_variables(app): + from flexmeasures.data.models.charts.belief_charts import CHARGE_POINT_CHART_NAMES + # Set variables for Jinja template context for v, d in ( ("FLEXMEASURES_MODE", ""), @@ -333,6 +335,7 @@ def add_jinja_variables(app): app.jinja_env.globals["js_versions"] = { key: library.version for key, library in app.extensions[APP_EXTENSION].items() } + app.jinja_env.globals["charge_point_chart_names"] = CHARGE_POINT_CHART_NAMES app.jinja_env.globals["vendor_url"] = vendor_url app.jinja_env.globals["vendor_attrs"] = vendor_attrs app.jinja_env.globals["sphinx_docs_exist"] = ( diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index e7b3e39f0d..a23e637d67 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2142,16 +2142,14 @@ function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, line return series; } -// The two companion subplots Vega-Lite shows alongside the sessions chart (when -// present in the dashboard config), restricted the same way the Python chart -// builder restricts them (see chart_for_chargepoint_sessions in belief_charts.py). -const CHARGEPOINT_COMPANION_TITLES = ["Power flow by type", "Prices"]; -const CHARGEPOINT_POWER_SENSOR_NAME = "charge points power"; -// Title of the dashboard group holding the raw session-marker sensors (arrival, -// departure, plug in/out, start/stop charging). Their values are epoch timestamps, -// not plottable quantities, so — like Vega-Lite — only the dedicated Charge Point -// sessions chart type shows them; the default multi-sensor view hides this group. -const CHARGE_POINT_SESSIONS_GROUP_TITLE = "Charge Point sessions"; +// The graph titles and sensor name that the Charge Points chart goes by come in as opts.chargePointChart, +// from the same constants the Python chart builder uses (CHARGE_POINT_CHART_NAMES in belief_charts.py): +// - sessionsTitle: the dashboard group holding the raw session-marker sensors (arrival, departure, plug in/out, start/stop charging). +// Their values are epoch timestamps, not plottable quantities, so (like Vega-Lite) only the Charge Points chart type shows them; +// the default multi-sensor view hides this group. +// - companionTitles: the two companion subplots Vega-Lite shows alongside the sessions chart (when present in the dashboard config), +// restricted the same way the Python chart builder restricts them (see chart_for_chargepoint_sessions in belief_charts.py). +// - powerFlowTitle and powerSensorName: of the power flows, only this sensor is shown. // Graph titles are matched as the Vega-Lite chart shows them, i.e. with a capital first letter, // so a graph titled "prices" counts as a Prices graph (see charge_point_companion_graph in belief_charts.py). @@ -2163,13 +2161,15 @@ function capitalizeTitle(title) { * Whether the Charge Points chart shows this graph below its sessions. * * @param {string} title - The graph's title, as in sensors_to_show. + * @param {Object} chargePointChart - The names the Charge Points chart goes by (CHARGE_POINT_CHART_NAMES in belief_charts.py). * @returns {boolean} */ -export function isChargePointCompanionTitle(title) { - return CHARGEPOINT_COMPANION_TITLES.includes(capitalizeTitle(title)); +export function isChargePointCompanionTitle(title, chargePointChart) { + return ((chargePointChart || {}).companionTitles || []).includes(capitalizeTitle(title)); } export function buildChargePointSessionsOption(elementId, data, opts) { + const chargePointChart = opts.chargePointChart || {}; const sessions = pivotChargePointSessions(data); if (sessions.length === 0) { return null; @@ -2197,16 +2197,16 @@ export function buildChargePointSessionsOption(elementId, data, opts) { // Same two companion subplots Vega-Lite stacks below the sessions chart, built // via the normal multi-sensor line-chart renderer and spliced in underneath. - const companionSpec = (opts.groupSpec || []).filter((g) => isChargePointCompanionTitle(g.title)); + const companionSpec = (opts.groupSpec || []).filter((g) => isChargePointCompanionTitle(g.title, chargePointChart)); let companionGroups = companionSpec.length > 0 ? groupData(data, companionSpec) : []; // groupData() falls back to grouping any row not covered by companionSpec's // sensorIds by unit, producing extra ad-hoc groups for the asset's other // sensors (soc, power, ...) — drop everything except the two intended titles. companionGroups = companionGroups - .filter((g) => isChargePointCompanionTitle(g.title)) + .filter((g) => isChargePointCompanionTitle(g.title, chargePointChart)) .map((g) => - capitalizeTitle(g.title) === "Power flow by type" - ? Object.assign({}, g, { series: g.series.filter((s) => s.sensorName === CHARGEPOINT_POWER_SENSOR_NAME) }) + capitalizeTitle(g.title) === chargePointChart.powerFlowTitle + ? Object.assign({}, g, { series: g.series.filter((s) => s.sensorName === chargePointChart.powerSensorName) }) : g ) .filter((g) => g.series.length > 0); @@ -2279,7 +2279,7 @@ export function buildChargePointSessionsOption(elementId, data, opts) { splitLine: { show: false }, }]; const titles = [{ - text: "Charge Point sessions", + text: chargePointChart.sessionsTitle, left: plotCenter, textAlign: "center", top: sessionsTop - TITLE_RAISE, @@ -2459,7 +2459,8 @@ export function renderFastChart(elementId, data, options) { } else if (opts.chartType === "chart_for_chargepoint_sessions") { option = buildChargePointSessionsOption(elementId, data, opts); } else { - const groupSpec = (opts.groupSpec || []).filter((g) => g.title !== CHARGE_POINT_SESSIONS_GROUP_TITLE); + const sessionsTitle = (opts.chargePointChart || {}).sessionsTitle; + const groupSpec = (opts.groupSpec || []).filter((g) => g.title !== sessionsTitle); const groups = groupData(data, groupSpec); option = groups.length > 0 ? buildLineBarOption(elementId, groups, opts) : null; } diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index 7c21824d2b..36acbed46b 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -73,6 +73,8 @@ let fullBeliefInfo = localStorage.getItem('fm-full-belief-info') === '1'; let syncFullBeliefInfoToggle = () => {}; const fastChartElementId = 'fast-chart-container'; + // The graph titles and sensor name the Charge Points chart goes by, as the Python chart builder names them. + const chargePointChartNames = {{ charge_point_chart_names | tojson }}; let fastChartGroupSpec = null; // subplot structure (titles + sensor groupings), mirroring the Vega-Lite chart // sensor id -> sensor type, seeded from the server-computed groupSpec, so we can // rebuild the groupSpec client-side (with y-axis titles) after an in-browser @@ -379,7 +381,7 @@ const beliefTooltipSupported = () => { if ('{{ active_page }}' === 'sensors') { return chartType === 'bar_chart'; } if (chartType !== 'chart_for_chargepoint_sessions') { return true; } - return (fastChartGroupSpec || []).some((group) => isChargePointCompanionTitle(group.title)); + return (fastChartGroupSpec || []).some((group) => isChargePointCompanionTitle(group.title, chargePointChartNames)); }; syncFullBeliefInfoToggle = () => { if (!fullBeliefInfoToggleContainer) { return; } @@ -1046,6 +1048,7 @@ function fastChartOptions() { return { groupSpec: fastChartGroupSpec, + chargePointChart: chargePointChartNames, // chart types (bar/histogram/heatmap) only apply on the sensor page; on // the asset page it's either the Charge Point sessions chart or the // default multi-sensor line chart. diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index 80fd9bec72..9cbe4a1868 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -1,5 +1,9 @@ """Tests for flexmeasures/ui/static/js/fast-chart.js.""" +import json + +from flexmeasures.data.models.charts.belief_charts import CHARGE_POINT_CHART_NAMES + def test_visible_time_range_tracks_mouse_zoom(assert_js): """The visible time range follows mouse zoom and resets to the full domain.""" @@ -564,10 +568,11 @@ def test_charge_point_companion_titles_match_whatever_the_case_of_their_first_le """Like the Vega-Lite chart, the fast chart capitalizes a graph title before matching it.""" assert_js(""" import { isChargePointCompanionTitle } from "/js/fast-chart.js"; + const names = CHARGE_POINT_CHART_NAMES; for (const title of ["Prices", "prices", "Power flow by type", "power flow by type"]) { - eq(`"${title}" is a companion graph`, isChargePointCompanionTitle(title), true); + eq(`"${title}" is a companion graph`, isChargePointCompanionTitle(title, names), true); } for (const title of ["Weather", "Charge Point sessions", "PRICES", "", null, undefined]) { - eq(`"${title}" is not a companion graph`, isChargePointCompanionTitle(title), false); + eq(`"${title}" is not a companion graph`, isChargePointCompanionTitle(title, names), false); } - """) + """.replace("CHARGE_POINT_CHART_NAMES", json.dumps(CHARGE_POINT_CHART_NAMES))) From 7466400b7b5a86716406a0666a90071b8fce8f4c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 16:51:10 +0100 Subject: [PATCH 10/20] Load an asset's chart data by the registry's layout of the chart type, and refuse unknown chart types GenericAsset.chart_sensors now takes the sensors from the chart type's layout in the registry of asset chart types, instead of matching graph titles itself, so what chart_data loads and what the chart endpoint draws come from one place. chart_data checks its chart-type against the registry, and takes the same values as the chart endpoint: "default" and "charge-point-sessions", and the deprecated legacy names, under chart-type or its snake_case alias. Anything else gets a 422. Without chart-type, or with an empty one (which main ignored), chart_data still returns every sensor in sensors_to_show. The tests use the charge_hub_fresh_db fixture from flexmeasures/conftest.py, which this module's own fixture had become a copy of. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/assets.py | 10 +- .../tests/test_asset_chart_data_fresh_db.py | 209 +++++++----------- .../data/models/charts/belief_charts.py | 21 -- flexmeasures/data/models/generic_assets.py | 40 +--- flexmeasures/data/schemas/charts.py | 14 +- flexmeasures/ui/static/openapi-specs.json | 8 +- .../ui/tests/js/test_chart_data_cache.py | 12 +- 7 files changed, 127 insertions(+), 187 deletions(-) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index f245494aa5..0ad2bc36d3 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -265,6 +265,7 @@ class AssetChartDataKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): "use_latest_version_per_event": "use-latest-version-per-event", "most_recent_beliefs_only": "most-recent-beliefs-only", "compress_json": "compress-json", + "chart_type": "chart-type", } use_latest_version_per_event = fields.Boolean( @@ -289,12 +290,15 @@ class AssetChartDataKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): description="If true, compress the JSON response.", ), ) - chart_type = fields.Str( + chart_type = AssetChartTypeField( data_key="chart-type", required=False, + accept_legacy_names=True, + empty_means_absent=True, metadata=dict( - description="Return only the data that this chart type draws, e.g. 'chart_for_multiple_sensors' or 'chart_for_chargepoint_sessions'; without it, return the data of every sensor in the asset's sensors_to_show.", - example="chart_for_multiple_sensors", + description=f"Return only the data of the sensors that this chart type draws: {describe_asset_chart_types(include_legacy_names=True)}. " + "Without it, or with an empty value, return the data of every sensor in the asset's sensors_to_show.", + example=DEFAULT_ASSET_CHART_TYPE, ), ) diff --git a/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py b/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py index 61e77f337f..0c881fb9c8 100644 --- a/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py @@ -2,6 +2,7 @@ The default chart leaves the graph of charging-session timestamps to the Charge Points chart, so loading those sensors for it only fetches data that is thrown away. +The charge_hub_fresh_db fixture (in flexmeasures/conftest.py) is a site with the graphs a site with charge points gets. """ from __future__ import annotations @@ -14,94 +15,17 @@ from flask import url_for from flexmeasures import Sensor -from flexmeasures.data.models.data_sources import DataSource -from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType -from flexmeasures.data.models.time_series import TimedBelief +# When the charge_hub_fresh_db fixture's beliefs are about. START = datetime(2015, 1, 1, tzinfo=pytz.utc) -@pytest.fixture -def charge_hub(fresh_db) -> dict[str, Sensor]: - """A public site whose dashboard has the graphs a site with charge points gets. - - One belief per sensor, so the chart data shows which sensors were loaded. - The departure sensor is also in a graph of its own, - to check that the default chart keeps a session sensor it draws elsewhere. - """ - site_type = GenericAssetType(name="test_site_type_for_chart_sensors") - fresh_db.session.add(site_type) - site = GenericAsset( - name="Test charge hub (chart sensors)", generic_asset_type=site_type - ) - fresh_db.session.add(site) - fresh_db.session.flush() - - units = { - "charge points power": "kW", - "building power": "kW", - "consumption price": "EUR/MWh", - "temperature": "°C", - "arrival": "s", - "departure": "s", - "plug in": "s", - } - sensors = { - name: Sensor( - name=name, - unit=unit, - event_resolution=timedelta(minutes=15), - generic_asset=site, - ) - for name, unit in units.items() - } - fresh_db.session.add_all(sensors.values()) - fresh_db.session.flush() - - site.sensors_to_show = [ - { - "title": "Power flow by type", - "sensors": [ - sensors["charge points power"].id, - sensors["building power"].id, - ], - }, - {"title": "Prices", "sensors": [sensors["consumption price"].id]}, - {"title": "Weather", "sensors": [sensors["temperature"].id]}, - {"title": "Departures", "sensors": [sensors["departure"].id]}, - { - "title": "Charge Point sessions", - "sensors": [ - sensors["arrival"].id, - sensors["departure"].id, - sensors["plug in"].id, - ], - }, - ] - - source = DataSource(name="test script", type="demo script") - fresh_db.session.add(source) - fresh_db.session.flush() - for sensor in sensors.values(): - fresh_db.session.add( - TimedBelief( - sensor=sensor, - event_start=START, - event_value=1.0, - belief_horizon=timedelta(0), - source=source, - ) - ) - fresh_db.session.commit() - return sensors - - def _names(sensors: list[Sensor]) -> list[str]: return [sensor.name for sensor in sensors] -def test_without_a_chart_type_all_sensors_are_loaded(charge_hub): - site = charge_hub["arrival"].generic_asset +def test_without_a_chart_type_all_sensors_are_loaded(charge_hub_fresh_db): + site = charge_hub_fresh_db["arrival"].generic_asset assert _names(site.chart_sensors()) == [ "charge points power", "building power", @@ -113,9 +37,9 @@ def test_without_a_chart_type_all_sensors_are_loaded(charge_hub): ] -def test_default_chart_skips_the_charge_point_sessions_graph(charge_hub): - site = charge_hub["arrival"].generic_asset - assert _names(site.chart_sensors("chart_for_multiple_sensors")) == [ +def test_default_chart_skips_the_charge_point_sessions_graph(charge_hub_fresh_db): + site = charge_hub_fresh_db["arrival"].generic_asset + assert _names(site.chart_sensors("default")) == [ "charge points power", "building power", "consumption price", @@ -126,10 +50,10 @@ def test_default_chart_skips_the_charge_point_sessions_graph(charge_hub): def test_charge_points_chart_loads_its_sessions_and_two_companion_graphs( - charge_hub, + charge_hub_fresh_db, ): - site = charge_hub["arrival"].generic_asset - assert _names(site.chart_sensors("chart_for_chargepoint_sessions")) == [ + site = charge_hub_fresh_db["arrival"].generic_asset + assert _names(site.chart_sensors("charge-point-sessions")) == [ "departure", "arrival", "plug in", @@ -166,10 +90,10 @@ def _sensor_ids_drawn(chart_specs: dict | list) -> set[int]: ], ) def test_charge_points_chart_loads_what_it_draws_whatever_the_title_case( - fresh_db, charge_hub, prices_title, power_flow_title + fresh_db, charge_hub_fresh_db, prices_title, power_flow_title ): """The Charge Points chart capitalizes graph titles, so a lowercase companion title still counts.""" - site = charge_hub["arrival"].generic_asset + site = charge_hub_fresh_db["arrival"].generic_asset titles = {"Prices": prices_title, "Power flow by type": power_flow_title} site.sensors_to_show = [ {**entry, "title": titles.get(entry["title"], entry["title"])} @@ -177,7 +101,7 @@ def test_charge_points_chart_loads_what_it_draws_whatever_the_title_case( ] fresh_db.session.commit() - loaded = site.chart_sensors("chart_for_chargepoint_sessions") + loaded = site.chart_sensors("charge-point-sessions") assert _names(loaded) == [ "departure", "arrival", @@ -185,69 +109,102 @@ def test_charge_points_chart_loads_what_it_draws_whatever_the_title_case( "charge points power", "consumption price", ] - chart_specs = site.chart(chart_type="chart_for_chargepoint_sessions") + chart_specs = site.chart(chart_type="charge-point-sessions") assert _sensor_ids_drawn(chart_specs) == {sensor.id for sensor in loaded} +ALL_SENSORS = { + "charge points power", + "building power", + "consumption price", + "temperature", + "arrival", + "departure", + "plug in", +} +DEFAULT_CHART_SENSORS = { + "charge points power", + "building power", + "consumption price", + "temperature", + "departure", +} +CHARGE_POINTS_CHART_SENSORS = { + "charge points power", + "consumption price", + "arrival", + "departure", + "plug in", +} + + @pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +# The same parameter name as the chart endpoint takes, including its snake_case alias. +@pytest.mark.parametrize("parameter", ["chart-type", "chart_type"]) @pytest.mark.parametrize( "chart_type, expected_names", [ - ( - None, - { - "charge points power", - "building power", - "consumption price", - "temperature", - "arrival", - "departure", - "plug in", - }, - ), - ( - "chart_for_multiple_sensors", - { - "charge points power", - "building power", - "consumption price", - "temperature", - "departure", - }, - ), - ( - "chart_for_chargepoint_sessions", - { - "charge points power", - "consumption price", - "arrival", - "departure", - "plug in", - }, - ), + (None, ALL_SENSORS), + # An empty value counts as none. + ("", ALL_SENSORS), + ("default", DEFAULT_CHART_SENSORS), + ("charge-point-sessions", CHARGE_POINTS_CHART_SENSORS), + # The legacy names, as the chart endpoint takes them. + ("chart_for_multiple_sensors", DEFAULT_CHART_SENSORS), + ("chart_for_chargepoint_sessions", CHARGE_POINTS_CHART_SENSORS), ], ) def test_chart_data_holds_only_what_the_chart_type_draws( client, setup_roles_users_fresh_db, - charge_hub, + charge_hub_fresh_db, requesting_user, + parameter, chart_type, expected_names, ): - site = charge_hub["arrival"].generic_asset + site = charge_hub_fresh_db["arrival"].generic_asset query = { "event_starts_after": START.isoformat(), "event_ends_before": (START + timedelta(hours=1)).isoformat(), "compress_json": "true", } if chart_type is not None: - query["chart-type"] = chart_type + query[parameter] = chart_type response = client.get( url_for("AssetAPI:get_chart_data", id=site.id), query_string=query ) assert response.status_code == 200, response.data # The endpoint sends the JSON as a string, so it is not parsed for us. loaded_ids = {record["sid"] for record in json.loads(response.data)["data"]} - id_by_name = {name: sensor.id for name, sensor in charge_hub.items()} + id_by_name = {name: sensor.id for name, sensor in charge_hub_fresh_db.items()} assert loaded_ids == {id_by_name[name] for name in expected_names} + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +@pytest.mark.parametrize( + "chart_type", + [ + "bar_chart", + "Default", + "chart_for_chargepoint_session", + ], +) +def test_chart_data_refuses_an_unknown_chart_type( + client, + setup_roles_users_fresh_db, + charge_hub_fresh_db, + requesting_user, + chart_type, +): + site = charge_hub_fresh_db["arrival"].generic_asset + response = client.get( + url_for("AssetAPI:get_chart_data", id=site.id), + query_string={ + "event_starts_after": START.isoformat(), + "event_ends_before": (START + timedelta(hours=1)).isoformat(), + "chart-type": chart_type, + }, + ) + assert response.status_code == 422, response.data + assert "default, charge-point-sessions" in response.get_data(as_text=True) diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index f895260402..277416d2c5 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -92,27 +92,6 @@ def charge_point_companion_graph(entry: dict) -> dict | None: return graph if _extract_sensors_from_entry(graph) else None -def charge_point_chart_sensors( - sensors_to_show: list[dict], -) -> list["Sensor"]: # noqa F821 - """The sensors whose data the Charge Points chart draws. - - These are the session timestamps in any graph, - followed by the sensors of the companion graphs it draws below them (see charge_point_companion_graph). - - :param sensors_to_show: a validated sensors_to_show. - :returns: a unique list of sensors. - """ - session_sensors = charge_point_session_sensors(sensors_to_show) - companion_sensors = [ - sensor - for entry in sensors_to_show - if (graph := charge_point_companion_graph(entry)) is not None - for sensor in _extract_sensors_from_entry(graph) - ] - return list(dict.fromkeys(session_sensors + companion_sensors)) - - def create_bar_chart_or_histogram_specs( sensor: "Sensor", # noqa F821 event_starts_after: datetime | None = None, diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index a1be0b67b4..63de7344b9 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -17,14 +17,7 @@ from flexmeasures.data import db from flexmeasures.data.models.legacy_migration_utils import upgrade_value from flexmeasures.data.models.annotations import Annotation, to_annotation_frame -from flexmeasures.data.models.charts.chart_types import ( - LEGACY_ASSET_CHART_TYPE_NAMES, - get_asset_chart_type, -) -from flexmeasures.data.models.charts.belief_charts import ( - CHARGE_POINT_SESSIONS_TITLE, - charge_point_chart_sensors, -) +from flexmeasures.data.models.charts.chart_types import get_asset_chart_type from flexmeasures.data.models.data_sources import DataSource from flexmeasures.data.models.parsing_utils import parse_source_arg from flexmeasures.data.models.user import User @@ -1000,31 +993,22 @@ def chart_sensors( ) -> list["Sensor"]: # noqa F821 """The sensors in this asset's sensors_to_show whose data the given chart type draws. - The default chart leaves the graph of charging-session timestamps to the Charge Points chart, - and the Charge Points chart draws only those timestamps and its two companion graphs; - loading the rest would be loading data that is thrown away. - Any other chart type, or none, gets all sensors. + These come from the chart type's layout in the registry of asset chart types (see flexmeasures.data.models.charts.chart_types), + so what chart data loads is what the chart draws. + For instance, the default chart leaves the graph of charging-session timestamps to the Charge Points chart, + and the Charge Points chart draws only those timestamps and its two companion graphs. + Without a chart type, all sensors are returned. - :param chart_type: "default", "charge-point-sessions" (or their legacy names) or None. - :returns: a unique list of sensors, in the order of sensors_to_show (for the Charge Points chart, its session sensors first). + :param chart_type: name of the chart type (e.g. "default" or "charge-point-sessions"), or None. + :returns: a unique list of sensors, in the order of the chart type's layout (without a chart type, of sensors_to_show). + :raises UnknownAssetChartType: if no asset chart type goes by the given name. """ from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema sensors_to_show = self.validate_sensors_to_show() - chart_type = LEGACY_ASSET_CHART_TYPE_NAMES.get(chart_type, chart_type) - - if chart_type == "default": - # A sensor that is also in another graph is still drawn, so it stays. - return SensorsToShowSchema.flatten( - [ - entry - for entry in sensors_to_show - if entry.get("title") != CHARGE_POINT_SESSIONS_TITLE - ] - ) - if chart_type == "charge-point-sessions": - return charge_point_chart_sensors(sensors_to_show) - return SensorsToShowSchema.flatten(sensors_to_show) + if chart_type is None: + return SensorsToShowSchema.flatten(sensors_to_show) + return get_asset_chart_type(chart_type).sensors(sensors_to_show) def chart( self, diff --git a/flexmeasures/data/schemas/charts.py b/flexmeasures/data/schemas/charts.py index 9afb0212b3..b8df08ea0f 100644 --- a/flexmeasures/data/schemas/charts.py +++ b/flexmeasures/data/schemas/charts.py @@ -37,7 +37,12 @@ class AssetChartTypeField(fields.Str): An unknown name fails validation, which the API answers with a 422. """ - def __init__(self, accept_legacy_names: bool = False, **kwargs): + def __init__( + self, + accept_legacy_names: bool = False, + empty_means_absent: bool = False, + **kwargs, + ): """Set up the field. The name is checked against the registry first, and then by any validators passed as validate. @@ -45,8 +50,10 @@ def __init__(self, accept_legacy_names: bool = False, **kwargs): :param accept_legacy_names: whether to also accept the names the chart endpoint took before the chart types had public names, mapping them to the public names. + :param empty_means_absent: whether to read an empty value as no chart type (None), rather than refuse it. """ self.accept_legacy_names = accept_legacy_names + self.empty_means_absent = empty_means_absent extra_validators = kwargs.pop("validate", None) or [] if callable(extra_validators): extra_validators = [extra_validators] @@ -58,6 +65,11 @@ def __init__(self, accept_legacy_names: bool = False, **kwargs): metadata.setdefault("example", DEFAULT_ASSET_CHART_TYPE) super().__init__(**kwargs) + def deserialize(self, value, attr=None, data=None, **kwargs): + if self.empty_means_absent and value == "": + return None + return super().deserialize(value, attr, data, **kwargs) + def _deserialize(self, value, attr, data, **kwargs) -> str: name = super()._deserialize(value, attr, data, **kwargs) if self.accept_legacy_names: diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index a8470a1c12..c96acaf737 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4268,10 +4268,14 @@ { "in": "query", "name": "chart-type", - "description": "Return only the data that this chart type draws, e.g. 'chart_for_multiple_sensors' or 'chart_for_chargepoint_sessions'; without it, return the data of every sensor in the asset's sensors_to_show.", + "description": "Return only the data of the sensors that this chart type draws: 'default' (a graph for each entry in the asset's sensors_to_show, except the charging sessions, which only 'charge-point-sessions' draws); 'charge-point-sessions' (the charging sessions of the asset's charge points, with its prices and charge points power below them). The legacy values 'chart_for_multiple_sensors' (for 'default') and 'chart_for_chargepoint_sessions' (for 'charge-point-sessions') are deprecated, but still accepted. Without it, or with an empty value, return the data of every sensor in the asset's sensors_to_show.", "schema": { "type": "string", - "example": "chart_for_multiple_sensors" + "enum": [ + "default", + "charge-point-sessions" + ], + "example": "default" }, "required": false } diff --git a/flexmeasures/ui/tests/js/test_chart_data_cache.py b/flexmeasures/ui/tests/js/test_chart_data_cache.py index 5dfde6727a..de4f13bed8 100644 --- a/flexmeasures/ui/tests/js/test_chart_data_cache.py +++ b/flexmeasures/ui/tests/js/test_chart_data_cache.py @@ -127,8 +127,8 @@ def test_build_chart_data_url(assert_js): check("the default leaves it to the API", !buildChartDataUrl("/x", {start, end, mostRecentBeliefsOnly: true}).includes("most_recent_beliefs_only")); check("a chart type asks for only the data that chart draws", - buildChartDataUrl("/x", {start, end, chartType: "chart_for_chargepoint_sessions"}) - .includes("&chart-type=chart_for_chargepoint_sessions")); + buildChartDataUrl("/x", {start, end, chartType: "charge-point-sessions"}) + .includes("&chart-type=charge-point-sessions")); check("without one, the API returns every sensor's data", !buildChartDataUrl("/x", {start, end}).includes("chart-type")); """) @@ -139,12 +139,12 @@ def test_cache_does_not_reuse_records_loaded_for_another_chart_type(assert_js): assert_js(FAKE_API + """ import { createChartDataCache } from "/js/chart-data-cache.js"; const cache = createChartDataCache(); - await cache.load("/a", {start: day(10), end: day(17), chartType: "chart_for_multiple_sensors"}); - await cache.load("/a", {start: day(11), end: day(12), chartType: "chart_for_multiple_sensors"}); + await cache.load("/a", {start: day(10), end: day(17), chartType: "default"}); + await cache.load("/a", {start: day(11), end: day(12), chartType: "default"}); eq("the same chart type is answered from memory", requests.length, 1); - await cache.load("/a", {start: day(11), end: day(12), chartType: "chart_for_chargepoint_sessions"}); + await cache.load("/a", {start: day(11), end: day(12), chartType: "charge-point-sessions"}); eq("another chart type fetches its own data", requests.length, 2); - check("naming that chart type", requests[1].includes("chart-type=chart_for_chargepoint_sessions"), requests[1]); + check("naming that chart type", requests[1].includes("chart-type=charge-point-sessions"), requests[1]); """) From a7fa04205f68f59a64beca2057e2d3e2cbc8448e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 15:41:57 +0100 Subject: [PATCH 11/20] Hand the fast chart the Charge Points chart's names from the registry of asset chart types CHARGE_POINT_CHART_NAMES moves from belief_charts.py to chart_types.py, next to the layout of the Charge Points chart that it describes. The fast chart still lays out the graphs itself by these names, as before; taking the layout from the server is left for later (issue #2795). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/data/models/charts/belief_charts.py | 7 ------- flexmeasures/data/models/charts/chart_types.py | 13 +++++++++++++ flexmeasures/ui/__init__.py | 2 +- flexmeasures/ui/static/js/fast-chart.js | 4 ++-- flexmeasures/ui/templates/includes/graphs.html | 2 +- flexmeasures/ui/tests/js/test_fast_chart.py | 2 +- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index 277416d2c5..2e1dff8b63 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -33,13 +33,6 @@ CHARGE_POINT_COMPANION_TITLES = (PRICES_TITLE, POWER_FLOW_BY_TYPE_TITLE) # Of the power flows, the Charge Points chart shows only this one. CHARGE_POINT_POWER_SENSOR_NAME = "charge points power" -# The same names, for the fast chart in the browser (handed to it by includes/graphs.html), so it matches what is drawn here. -CHARGE_POINT_CHART_NAMES = { - "sessionsTitle": CHARGE_POINT_SESSIONS_TITLE, - "companionTitles": list(CHARGE_POINT_COMPANION_TITLES), - "powerFlowTitle": POWER_FLOW_BY_TYPE_TITLE, - "powerSensorName": CHARGE_POINT_POWER_SENSOR_NAME, -} def charge_point_session_sensors( diff --git a/flexmeasures/data/models/charts/chart_types.py b/flexmeasures/data/models/charts/chart_types.py index ab16dc1c31..4a0f0d2fe2 100644 --- a/flexmeasures/data/models/charts/chart_types.py +++ b/flexmeasures/data/models/charts/chart_types.py @@ -13,7 +13,10 @@ from typing import Callable from flexmeasures.data.models.charts.belief_charts import ( + CHARGE_POINT_COMPANION_TITLES, + CHARGE_POINT_POWER_SENSOR_NAME, CHARGE_POINT_SESSIONS_TITLE, + POWER_FLOW_BY_TYPE_TITLE, charge_point_session_sensors, charge_point_companion_graph, chart_for_chargepoint_sessions, @@ -115,6 +118,16 @@ def charge_point_sessions_layout(sensors_to_show: list[dict]) -> list[dict]: return [sessions_graph, *companion_graphs] +# The names that the layout of the Charge Points chart goes by, for the fast chart in the browser (handed to it by includes/graphs.html). +# The fast chart still lays out the graphs itself, by these names, so that it draws what charge_point_sessions_layout holds. +CHARGE_POINT_CHART_NAMES = { + "sessionsTitle": CHARGE_POINT_SESSIONS_TITLE, + "companionTitles": list(CHARGE_POINT_COMPANION_TITLES), + "powerFlowTitle": POWER_FLOW_BY_TYPE_TITLE, + "powerSensorName": CHARGE_POINT_POWER_SENSOR_NAME, +} + + def has_charge_points(asset: "GenericAsset") -> bool: # noqa F821 """Whether the asset has a charge point among its children, as the asset page checks before offering the Charge Points chart.""" return any( diff --git a/flexmeasures/ui/__init__.py b/flexmeasures/ui/__init__.py index f65de0fc56..550924c29e 100644 --- a/flexmeasures/ui/__init__.py +++ b/flexmeasures/ui/__init__.py @@ -319,7 +319,7 @@ def shared_sensor_type(sensors): def add_jinja_variables(app): - from flexmeasures.data.models.charts.belief_charts import CHARGE_POINT_CHART_NAMES + from flexmeasures.data.models.charts.chart_types import CHARGE_POINT_CHART_NAMES # Set variables for Jinja template context for v, d in ( diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index a2975348ee..7cc1284adf 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2143,7 +2143,7 @@ function buildSessionSegmentSeries(sessions, fromKey, toKey, categoryIndex, line } // The graph titles and sensor name that the Charge Points chart goes by come in as opts.chargePointChart, -// from the same constants the Python chart builder uses (CHARGE_POINT_CHART_NAMES in belief_charts.py): +// from the registry of asset chart types, whose layout of the Charge Points chart uses the same names (CHARGE_POINT_CHART_NAMES in chart_types.py): // - sessionsTitle: the dashboard group holding the raw session-marker sensors (arrival, departure, plug in/out, start/stop charging). // Their values are epoch timestamps, not plottable quantities, so (like Vega-Lite) only the Charge Points chart type shows them; // the default multi-sensor view hides this group. @@ -2161,7 +2161,7 @@ function capitalizeTitle(title) { * Whether the Charge Points chart shows this graph below its sessions. * * @param {string} title - The graph's title, as in sensors_to_show. - * @param {Object} chargePointChart - The names the Charge Points chart goes by (CHARGE_POINT_CHART_NAMES in belief_charts.py). + * @param {Object} chargePointChart - The names the Charge Points chart goes by (CHARGE_POINT_CHART_NAMES in chart_types.py). * @returns {boolean} */ export function isChargePointCompanionTitle(title, chargePointChart) { diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index c77ffc7101..306054556f 100644 --- a/flexmeasures/ui/templates/includes/graphs.html +++ b/flexmeasures/ui/templates/includes/graphs.html @@ -73,7 +73,7 @@ let fullBeliefInfo = localStorage.getItem('fm-full-belief-info') === '1'; let syncFullBeliefInfoToggle = () => {}; const fastChartElementId = 'fast-chart-container'; - // The graph titles and sensor name the Charge Points chart goes by, as the Python chart builder names them. + // The graph titles and sensor name the Charge Points chart goes by, as the registry of asset chart types names them. const chargePointChartNames = {{ charge_point_chart_names | tojson }}; let fastChartGroupSpec = null; // subplot structure (titles + sensor groupings), mirroring the Vega-Lite chart // sensor id -> sensor type, seeded from the server-computed groupSpec, so we can diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index 273d033917..8b77b0c46c 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -2,7 +2,7 @@ import json -from flexmeasures.data.models.charts.belief_charts import CHARGE_POINT_CHART_NAMES +from flexmeasures.data.models.charts.chart_types import CHARGE_POINT_CHART_NAMES def test_visible_time_range_tracks_mouse_zoom(assert_js): From 71af2bdf9fd7df7fda2d6a49f227efd8239965db Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 16:55:26 +0100 Subject: [PATCH 12/20] Name the chart types in the chart_data change log entry by their public names Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 4d9fe29dc7..48a3402c11 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -22,7 +22,7 @@ v3.0-40 | October XX, 2026 - ``POST /api/v3_0/assets//automations`` now also refuses a ``source`` which is not the caller's to work with for automation types registered by a plugin, where the refusal used to cover the built-in types only. - ``GET /api/v3_0/sensors//status`` now returns ``missing-members`` with the status of reporter data whose most recent aggregate of a device group, old enough to count as stale, had to leave out members, because they did not report: the ``id``, ``name`` and ``asset-name`` of each of those sensors. The field is left out where no member is missing, as for most sensors. - ``GET /api/v3_0/assets//chart`` now takes the public names of the asset chart types as ``chart-type``: ``default`` (the default) and ``charge-point-sessions``. It refuses an unknown ``chart-type`` with a ``422``, where it used to fail on it. The values it took so far, ``chart_for_multiple_sensors`` and ``chart_for_chargepoint_sessions``, are still accepted, as legacy values for ``default`` and ``charge-point-sessions``, respectively. -- ``GET /api/v3_0/assets//chart_data`` now accepts ``chart-type``, as ``GET /api/v3_0/assets//chart`` does, and then returns only the data that chart type draws. For ``chart_for_multiple_sensors``, that leaves out the sensors which appear only in a graph titled "Charge Point sessions"; for ``chart_for_chargepoint_sessions``, it is the charging-session sensors, ``charge points power`` from "Power flow by type", and the "Prices" sensors. Without ``chart-type``, or with another chart type, it returns the data of every sensor in the asset's ``sensors_to_show``, as before. +- ``GET /api/v3_0/assets//chart_data`` now accepts ``chart-type``, as ``GET /api/v3_0/assets//chart`` does, with the same values, and then returns only the data that chart type draws. For ``default``, that leaves out the sensors which appear only in a graph titled "Charge Point sessions"; for ``charge-point-sessions``, it is the charging-session sensors, ``charge points power`` from "Power flow by type", and the "Prices" sensors. An unknown ``chart-type`` gets a ``422``. Without ``chart-type``, or with an empty one, it returns the data of every sensor in the asset's ``sensors_to_show``, as before. v3.0-39 | September 22, 2026 """""""""""""""""""""""""""" From 18d3be4b7d67c10a84193288e0a0ad5787494afa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 16:56:34 +0100 Subject: [PATCH 13/20] Let an asset's fast chart fail loudly without the Charge Points chart's names Without them, it drew an untitled sessions chart, or a default chart showing the session timestamps. includes/graphs.html always passes them, so a missing set is a mistake in the caller; throwing makes it show in the console and in tests, instead of in a subtly wrong chart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/ui/static/js/fast-chart.js | 21 +++++++++- flexmeasures/ui/tests/js/test_fast_chart.py | 45 ++++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index 7cc1284adf..1a9a4bb8fd 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2168,8 +2168,24 @@ export function isChargePointCompanionTitle(title, chargePointChart) { return ((chargePointChart || {}).companionTitles || []).includes(capitalizeTitle(title)); } +/** + * The names the Charge Points chart goes by, which an asset chart needs to tell its graphs apart. + * + * Throws when they are missing, rather than drawing an untitled sessions chart, or a default chart that shows the session timestamps: + * includes/graphs.html always passes them, so their absence is a mistake in the caller, which should show. + * + * @param {Object} opts - The fast chart options. + * @returns {Object} - opts.chargePointChart. + */ +function requireChargePointChart(opts) { + if (!opts.chargePointChart) { + throw new Error("An asset chart needs opts.chargePointChart (CHARGE_POINT_CHART_NAMES in chart_types.py) to tell its graphs apart."); + } + return opts.chargePointChart; +} + export function buildChargePointSessionsOption(elementId, data, opts) { - const chargePointChart = opts.chargePointChart || {}; + const chargePointChart = requireChargePointChart(opts); const sessions = pivotChargePointSessions(data); if (sessions.length === 0) { return null; @@ -2459,7 +2475,8 @@ export function renderFastChart(elementId, data, options) { } else if (opts.chartType === "charge-point-sessions") { option = buildChargePointSessionsOption(elementId, data, opts); } else { - const sessionsTitle = (opts.chargePointChart || {}).sessionsTitle; + // On the sensor page there is one sensor, and no sessions graph to leave out. + const sessionsTitle = opts.isSensorPage ? undefined : requireChargePointChart(opts).sessionsTitle; const groupSpec = (opts.groupSpec || []).filter((g) => g.title !== sessionsTitle); const groups = groupData(data, groupSpec); option = groups.length > 0 ? buildLineBarOption(elementId, groups, opts) : null; diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index 8b77b0c46c..d2d07096e9 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -2,6 +2,8 @@ import json +import pytest + from flexmeasures.data.models.charts.chart_types import CHARGE_POINT_CHART_NAMES @@ -206,7 +208,7 @@ def test_charge_point_sessions_keep_their_own_tooltip(assert_js): const option = buildChargePointSessionsOption("sessions-chart", [ {sensor: sensor("arrival"), event_start: eventStart, event_value: eventStart}, {sensor: sensor("departure"), event_start: eventStart, event_value: eventStart + 3600000}, - ], {groupSpec: [], datasetName: "sessions", isSensorPage: false}); + ], {groupSpec: [], datasetName: "sessions", isSensorPage: false, chargePointChart: CHARGE_POINT_CHART_NAMES}); check("the session chart renders", option.series.length >= 2); const visibleSession = option.series.find((series) => series.tooltip && series.tooltip.formatter); @@ -214,7 +216,7 @@ def test_charge_point_sessions_keep_their_own_tooltip(assert_js): check("session start stays visible", tooltip.includes("Arrival")); check("session end stays visible", tooltip.includes("Departure")); check("session asset stays visible", tooltip.includes("Charger 4")); - """) + """.replace("CHARGE_POINT_CHART_NAMES", json.dumps(CHARGE_POINT_CHART_NAMES))) def test_annotation_hover_survives_canvas_exit_and_pin(assert_js): @@ -619,3 +621,42 @@ def test_the_charge_point_sessions_chart_type_draws_the_sessions(assert_js): disposeFastChart(container.id); container.remove(); """.replace("CHARGE_POINT_CHART_NAMES", json.dumps(CHARGE_POINT_CHART_NAMES))) + + +@pytest.mark.parametrize("chart_type", ["line", "charge-point-sessions"]) +def test_an_asset_chart_without_the_charge_point_chart_names_fails_loudly( + assert_js, chart_type +): + """Without the names, the fast chart cannot tell the asset's graphs apart, and should say so rather than draw the wrong graphs.""" + assert_js(""" + import { renderFastChart, disposeFastChart } from "/js/fast-chart.js"; + const container = document.createElement("div"); + container.id = "nameless-chart"; + container.style.width = "800px"; + document.body.appendChild(container); + let option; + const zr = {on() {}, off() {}}; + window.echarts = {init: () => ({ + isDisposed: () => false, resize() {}, dispose() {}, + setOption(value, opts) { if (opts?.notMerge) option = value; }, + getOption: () => option, + getDom: () => container, getZr: () => zr, + getModel: () => ({getSeriesByIndex: () => null}), + on() {}, off() {}, dispatchAction() {}, + })}; + const eventStart = Date.parse("2026-09-21T10:00:00Z"); + const rows = [{ + sensor: {id: 1, name: "arrival", unit: "s", asset_id: 4, asset_description: "Charger 4"}, + event_start: eventStart, event_value: eventStart, + }]; + let error = null; + try { + renderFastChart(container.id, rows, {chartType: "CHART_TYPE", groupSpec: [], datasetName: "asset_4", isSensorPage: false}); + } catch (e) { + error = e; + } + check("the chart refuses to render", error !== null); + check("and names what is missing", String(error).includes("chargePointChart"), String(error)); + disposeFastChart(container.id); + container.remove(); + """.replace("CHART_TYPE", chart_type)) From a8ffb3b0197255adc261191a92d013911a1da3a9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 16:57:56 +0100 Subject: [PATCH 14/20] Inline only the data of the sensors a chart type draws, in an asset chart with its data GenericAsset.chart(include_data=True), which the CLI uses to save a chart, now takes the sensors from the chart type's layout, as chart_data does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- .../tests/test_asset_chart_data_fresh_db.py | 24 +++++++++++++++++++ flexmeasures/data/models/generic_assets.py | 7 +++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py b/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py index 0c881fb9c8..2b7e30e3de 100644 --- a/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py @@ -208,3 +208,27 @@ def test_chart_data_refuses_an_unknown_chart_type( ) assert response.status_code == 422, response.data assert "default, charge-point-sessions" in response.get_data(as_text=True) + + +@pytest.mark.parametrize( + "chart_type, expected_names", + [ + ("default", DEFAULT_CHART_SENSORS), + ("charge-point-sessions", CHARGE_POINTS_CHART_SENSORS), + ], +) +def test_a_chart_with_its_data_holds_only_what_it_draws( + charge_hub_fresh_db, chart_type, expected_names +): + """As chart_data does, the data inlined in a chart covers only the sensors the chart type draws.""" + site = charge_hub_fresh_db["arrival"].generic_asset + chart_specs = site.chart( + chart_type=chart_type, + include_data=True, + event_starts_after=START, + event_ends_before=START + timedelta(hours=1), + dataset_name="chart", + ) + loaded_ids = {record["sensor"]["id"] for record in chart_specs["datasets"]["chart"]} + id_by_name = {name: sensor.id for name, sensor in charge_hub_fresh_db.items()} + assert loaded_ids == {id_by_name[name] for name in expected_names} diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 63de7344b9..7177b08156 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -1061,7 +1061,8 @@ def chart( kwargs["event_starts_after"] = event_starts_after if event_ends_before: kwargs["event_ends_before"] = event_ends_before - chart_specs = get_asset_chart_type(chart_type).chart_specs( + registered_chart_type = get_asset_chart_type(chart_type) + chart_specs = registered_chart_type.chart_specs( sensors_to_show=processed_sensors_to_show, dataset_name=dataset_name, combine_legend=combine_legend, @@ -1071,9 +1072,9 @@ def chart( ) if include_data: - # Get data + # Get the data of the sensors the chart draws, as chart_data does for this chart type data = self.chart_data_json( - sensors=sensors, + sensors=registered_chart_type.sensors(processed_sensors_to_show), event_starts_after=event_starts_after, event_ends_before=event_ends_before, beliefs_after=beliefs_after, From cee0bd2b697afd2d980e760a71e3372e055f85a7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 16:59:13 +0100 Subject: [PATCH 15/20] Note in the changelog what else holds only a chart's own data now, and the fast chart's lowercase titles fix Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 51b47fbc97..05ddd2537d 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -111,7 +111,7 @@ Infrastructure / Support * Every kind of data generator (e.g. a ``Forecaster``, ``Scheduler`` or ``Reporter``) now has its results checked and saved in the same way: all of a run's outputs are saved or none of them, and recording on a sensor the generator was not checked against raises ``GeneratorWritesUncheckedSensor`` [see `PR #2689 `_ and `PR #2571 `_] * Stop installing the libraries for passkey, authenticator-app and SMS sign-in, which FlexMeasures does not support, as it ships email-based two-factor authentication only; to enable the ``authenticator`` method anyway, install ``qrcode`` [see `PR #2564 `_] * Add a concepts page on data pipelining, which describes in one place how time-series data is referenced (sensor references, source filters, fallback values), cleaned on the way in and bounded on the way out, and link to it from the feature pages and tutorials [see `PR #2721 `_] -* On an asset with charge points, the Default chart no longer loads the charging-session sensors it doesn't draw, and the Charge Points chart loads only the sensors it draws; switching between them loads that chart's data [see `PR #2790 `_] +* On an asset with charge points, the Default chart no longer loads the charging-session sensors it doesn't draw, and the Charge Points chart loads only the sensors it draws; switching between them loads that chart's data, and a chart's CSV export, or a chart saved with ``flexmeasures show chart``, holds only the data of the sensors that chart draws [see `PR #2790 `_] Bugfixes ----------- @@ -161,6 +161,7 @@ Bugfixes * Deleting a sensor now unsets it in the flex-model and flex-context references to it that carry a default, bounds or source filters, which used to stay behind pointing at the deleted sensor; the rest of such a reference is kept for when a new sensor is picked, and until then, schedules use its default as a fixed value, or, without a default, ignore the field, so that an ancestor asset's value for it applies; the flex-config editors mark such fields, the asset's pages show a toast linking to them, and deleting a sensor lists the fields that will need a new one [see `PR #2726 `_, `PR #2767 `_ and `PR #2783 `_] * Offset chains, as in an automation's window or ``flexmeasures add report --start-offset``, now count days as calendar days across a DST transition, so a window no longer collapses or skips a day when run in the hour around midnight on such a weekend, and ``HB`` and ``DB`` no longer raise an error in the repeated hour or in a DST gap [see `PR #2771 `_] * In the Charge Points chart, a Power flow by type graph now shows only the charge points power also when its title starts with a lowercase letter [see `PR #XXXX `_] +* In the Charge Points chart drawn by the fast (default) renderer, a Prices or Power flow by type graph whose title starts with a lowercase letter is now shown, as the other renderer already did [see `PR #2790 `_] Automations, in detail From 5346fa1b10340633d1de207251a12ad5a4618b3a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 18:51:55 +0100 Subject: [PATCH 16/20] Note in the API changelog that an asset chart with its data inlines only what it draws Also, flexmeasures show chart writes an image, so it holds no data to narrow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 1 + documentation/changelog.rst | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 48a3402c11..2be5abb5f3 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -23,6 +23,7 @@ v3.0-40 | October XX, 2026 - ``GET /api/v3_0/sensors//status`` now returns ``missing-members`` with the status of reporter data whose most recent aggregate of a device group, old enough to count as stale, had to leave out members, because they did not report: the ``id``, ``name`` and ``asset-name`` of each of those sensors. The field is left out where no member is missing, as for most sensors. - ``GET /api/v3_0/assets//chart`` now takes the public names of the asset chart types as ``chart-type``: ``default`` (the default) and ``charge-point-sessions``. It refuses an unknown ``chart-type`` with a ``422``, where it used to fail on it. The values it took so far, ``chart_for_multiple_sensors`` and ``chart_for_chargepoint_sessions``, are still accepted, as legacy values for ``default`` and ``charge-point-sessions``, respectively. - ``GET /api/v3_0/assets//chart_data`` now accepts ``chart-type``, as ``GET /api/v3_0/assets//chart`` does, with the same values, and then returns only the data that chart type draws. For ``default``, that leaves out the sensors which appear only in a graph titled "Charge Point sessions"; for ``charge-point-sessions``, it is the charging-session sensors, ``charge points power`` from "Power flow by type", and the "Prices" sensors. An unknown ``chart-type`` gets a ``422``. Without ``chart-type``, or with an empty one, it returns the data of every sensor in the asset's ``sensors_to_show``, as before. +- ``GET /api/v3_0/assets//chart`` with ``include-data`` now likewise inlines only the data of the sensors the chosen chart type draws, where it used to inline the data of every sensor in the asset's ``sensors_to_show``. Without ``chart-type``, that is the ``default`` chart type. v3.0-39 | September 22, 2026 """""""""""""""""""""""""""" diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 05ddd2537d..d51f90696b 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -111,7 +111,7 @@ Infrastructure / Support * Every kind of data generator (e.g. a ``Forecaster``, ``Scheduler`` or ``Reporter``) now has its results checked and saved in the same way: all of a run's outputs are saved or none of them, and recording on a sensor the generator was not checked against raises ``GeneratorWritesUncheckedSensor`` [see `PR #2689 `_ and `PR #2571 `_] * Stop installing the libraries for passkey, authenticator-app and SMS sign-in, which FlexMeasures does not support, as it ships email-based two-factor authentication only; to enable the ``authenticator`` method anyway, install ``qrcode`` [see `PR #2564 `_] * Add a concepts page on data pipelining, which describes in one place how time-series data is referenced (sensor references, source filters, fallback values), cleaned on the way in and bounded on the way out, and link to it from the feature pages and tutorials [see `PR #2721 `_] -* On an asset with charge points, the Default chart no longer loads the charging-session sensors it doesn't draw, and the Charge Points chart loads only the sensors it draws; switching between them loads that chart's data, and a chart's CSV export, or a chart saved with ``flexmeasures show chart``, holds only the data of the sensors that chart draws [see `PR #2790 `_] +* On an asset with charge points, the Default chart no longer loads the charging-session sensors it doesn't draw, and the Charge Points chart loads only the sensors it draws; switching between them loads that chart's data, and a chart's CSV export, or a chart requested with its data from the API, holds only the data of the sensors that chart draws [see `PR #2790 `_] Bugfixes ----------- From ddc2b6e0977dd97bf95487ab2d22fa841cb7cac4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 18:52:28 +0100 Subject: [PATCH 17/20] Drop a double space from the AssetChartTypeField docstring Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/charts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/charts.py b/flexmeasures/data/schemas/charts.py index b8df08ea0f..292e4c0f58 100644 --- a/flexmeasures/data/schemas/charts.py +++ b/flexmeasures/data/schemas/charts.py @@ -50,7 +50,7 @@ def __init__( :param accept_legacy_names: whether to also accept the names the chart endpoint took before the chart types had public names, mapping them to the public names. - :param empty_means_absent: whether to read an empty value as no chart type (None), rather than refuse it. + :param empty_means_absent: whether to read an empty value as no chart type (None), rather than refuse it. """ self.accept_legacy_names = accept_legacy_names self.empty_means_absent = empty_means_absent From a0e25f4e29211850751706e0d560de69046845f4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 18:54:17 +0100 Subject: [PATCH 18/20] Test that the Charge Points chart's names passed in drive the fast chart Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/ui/tests/js/test_fast_chart.py | 59 +++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index d2d07096e9..c9f8aeffc8 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -623,6 +623,65 @@ def test_the_charge_point_sessions_chart_type_draws_the_sessions(assert_js): """.replace("CHARGE_POINT_CHART_NAMES", json.dumps(CHARGE_POINT_CHART_NAMES))) +def test_the_charge_point_chart_names_passed_in_drive_the_sessions_chart(assert_js): + """The fast chart takes its titles and power sensor from the names it is handed, not from copies of its own.""" + assert_js(""" + import { renderFastChart, disposeFastChart } from "/js/fast-chart.js"; + const names = { + sessionsTitle: "Renamed sessions", + companionTitles: ["Renamed prices", "Renamed flows"], + powerFlowTitle: "Renamed flows", + powerSensorName: "renamed power", + }; + const container = document.createElement("div"); + container.id = "renamed-sessions-chart"; + container.style.width = "800px"; + document.body.appendChild(container); + let option; + const zr = {on() {}, off() {}}; + window.echarts = {init: () => ({ + isDisposed: () => false, resize() {}, dispose() {}, + setOption(value, opts) { if (opts?.notMerge) option = value; }, + getOption: () => option, + getDom: () => container, getZr: () => zr, + getModel: () => ({getSeriesByIndex: () => null}), + on() {}, off() {}, dispatchAction() {}, + })}; + const eventStart = Date.parse("2026-09-21T10:00:00Z"); + const charger = {asset_id: 4, asset_description: "Charger 4"}; + const reading = (id, name, unit, value) => ({ + sensor: {id, name, unit, ...charger}, source: {id: 1}, event_start: eventStart, event_value: value, + }); + const rows = [ + {sensor: {id: 1, name: "arrival", unit: "s", ...charger}, event_start: eventStart, event_value: eventStart}, + {sensor: {id: 2, name: "departure", unit: "s", ...charger}, event_start: eventStart, event_value: eventStart + 3600000}, + reading(10, "tariff", "EUR/MWh", 50), + reading(11, "renamed power", "kW", 3), + reading(12, "charge points power", "kW", 4), + reading(13, "price", "EUR/MWh", 60), + ]; + renderFastChart(container.id, rows, { + chartType: "charge-point-sessions", + groupSpec: [ + {title: "Renamed prices", sensorIds: [10]}, + {title: "Renamed flows", sensorIds: [11, 12]}, + {title: "Prices", sensorIds: [13]}, + ], + datasetName: "asset_4", isSensorPage: false, chargePointChart: names, + }); + const titles = [].concat(option.title || []).map((title) => title.text); + eq("the graphs are titled by the names passed in", JSON.stringify(titles), + JSON.stringify(["Renamed sessions", "Renamed prices", "Renamed flows"])); + const seriesNames = option.series.map((series) => series.name); + check("the power flows show the power sensor named", seriesNames.some((name) => String(name).includes("renamed power")), + JSON.stringify(seriesNames)); + check("and no other power sensor", !seriesNames.some((name) => String(name).includes("charge points power")), + JSON.stringify(seriesNames)); + disposeFastChart(container.id); + container.remove(); + """) + + @pytest.mark.parametrize("chart_type", ["line", "charge-point-sessions"]) def test_an_asset_chart_without_the_charge_point_chart_names_fails_loudly( assert_js, chart_type From 5249165a49eecf82b7780b4092cdf7cbf4d77df7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 23:10:04 +0100 Subject: [PATCH 19/20] Leave chart-type on chart_data out of the API docs, for the asset page only Its values are the names of the chart functions, which public chart type names replace in v1.2.0 (#2795), so the official API makes no promise about it yet: the OpenAPI specs leave it out, as they do beliefs-after, and the API changelog no longer announces it. The endpoint still accepts it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 1 - flexmeasures/api/v3_0/assets.py | 12 ++++++++++-- .../v3_0/tests/test_asset_chart_data_fresh_db.py | 13 +++++++++++++ flexmeasures/ui/static/openapi-specs.json | 10 ---------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 251f0828fe..378806dfcf 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -21,7 +21,6 @@ v3.0-40 | October XX, 2026 - Data sources returned by ``GET /api/v3_0/sources`` and ``GET /api/v3_0/sources/`` now carry the ``account_id`` of the organisation they record for, where only sources of type ``user`` used to. Source filters that select by organisation, such as ``source-account`` on ``GET /api/v3_0/sensors//data`` and inside sensor references in a flex-model or flex-context, therefore match data computed by forecasters, schedulers and reporters, where they used to match user-recorded data only. A source belonging to no organisation is one the host runs for everyone. - ``POST /api/v3_0/assets//automations`` now also refuses a ``source`` which is not the caller's to work with for automation types registered by a plugin, where the refusal used to cover the built-in types only. - ``GET /api/v3_0/sensors//status`` now returns ``missing-members`` with the status of reporter data whose most recent aggregate of a device group, old enough to count as stale, had to leave out members, because they did not report: the ``id``, ``name`` and ``asset-name`` of each of those sensors. The field is left out where no member is missing, as for most sensors. -- ``GET /api/v3_0/assets//chart_data`` now accepts ``chart-type``, as ``GET /api/v3_0/assets//chart`` does, and then returns only the data that chart type draws. For ``chart_for_multiple_sensors``, that leaves out the sensors which appear only in a graph titled "Charge Point sessions"; for ``chart_for_chargepoint_sessions``, it is the charging-session sensors, ``charge points power`` from "Power flow by type", and the "Prices" sensors. Without ``chart-type``, or with another chart type, it returns the data of every sensor in the asset's ``sensors_to_show``, as before. v3.0-39 | September 22, 2026 """""""""""""""""""""""""""" diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 4395f4e9df..a11c8a35c5 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -280,6 +280,8 @@ class AssetChartDataKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): description="If true, compress the JSON response.", ), ) + # For the asset page only, so left out of the API docs (see AssetChartDataOpenAPISchema): + # its values are the names of the chart functions, which public chart type names replace in v1.2.0 (see issue #2795). chart_type = fields.Str( data_key="chart-type", required=False, @@ -317,10 +319,16 @@ def __init__(self, *args, **kwargs): class AssetChartDataOpenAPISchema(AssetChartDataKwargsSchema): - """Doc-only variant of `AssetChartDataKwargsSchema`; see `AssetChartOpenAPISchema`.""" + """Doc-only variant of `AssetChartDataKwargsSchema`; see `AssetChartOpenAPISchema`. + + It also hides `chart_type`, which only the asset page uses for now. + """ def __init__(self, *args, **kwargs): - kwargs["exclude"] = list(kwargs.get("exclude", [])) + ["beliefs_after"] + kwargs["exclude"] = list(kwargs.get("exclude", [])) + [ + "beliefs_after", + "chart_type", + ] super().__init__(*args, **kwargs) diff --git a/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py b/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py index 61e77f337f..cae4e71a86 100644 --- a/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_asset_chart_data_fresh_db.py @@ -251,3 +251,16 @@ def test_chart_data_holds_only_what_the_chart_type_draws( loaded_ids = {record["sid"] for record in json.loads(response.data)["data"]} id_by_name = {name: sensor.id for name, sensor in charge_hub.items()} assert loaded_ids == {id_by_name[name] for name in expected_names} + + +def test_chart_type_is_left_out_of_the_chart_data_docs(): + """Only the asset page uses chart-type on chart_data, so the published specs leave it out, until public chart type names replace its values (issue #2795).""" + from importlib.resources import files + + specs = json.loads( + files("flexmeasures.ui").joinpath("static/openapi-specs.json").read_text() + ) + parameters = specs["paths"]["/api/v3_0/assets/{id}/chart_data"]["get"]["parameters"] + names = [parameter["name"] for parameter in parameters] + assert "start" in names + assert "chart-type" not in names diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index bc6a4f80ce..35621ee5f5 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4258,16 +4258,6 @@ "type": "boolean" }, "required": false - }, - { - "in": "query", - "name": "chart-type", - "description": "Return only the data that this chart type draws, e.g. 'chart_for_multiple_sensors' or 'chart_for_chargepoint_sessions'; without it, return the data of every sensor in the asset's sensors_to_show.", - "schema": { - "type": "string", - "example": "chart_for_multiple_sensors" - }, - "required": false } ], "responses": { From 4b53db735afbd5b0c0685845299d4e7115eef97d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 3 Nov 2026 09:15:47 +0100 Subject: [PATCH 20/20] Fill in the PR number in the changelog entry Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 444a6e4e02..362bfa49ce 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -11,7 +11,7 @@ v1.2.0 | XX, 2026 New features ------------- * Asset and sensor charts have public chart type names (``default`` and ``charge-point-sessions`` for assets; ``bar-chart``, ``histogram``, ``daily-heatmap`` and ``weekly-heatmap`` for sensors), which the chart endpoints document and check, while they still accept the names they took so far; plugins can register their own asset and sensor chart types, which the asset and sensor pages offer where they apply [see `PR #2803 `_] -* The data of an asset's chart can be asked for by the public chart type names, of the built-in chart types and of those that plugins register, and holds only the data of the sensors that chart type draws, also in an asset chart's CSV export and in a chart requested with its data from the API [see `PR #XXXX `_] +* The data of an asset's chart can be asked for by the public chart type names, of the built-in chart types and of those that plugins register, and holds only the data of the sensors that chart type draws, also in an asset chart's CSV export and in a chart requested with its data from the API [see `PR #2805 `_] Bugfixes -----------