From a4434a93023ff8bbab94ae393d1318db44490cd2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 15:20:48 +0100 Subject: [PATCH 01/26] Decide in one place which graphs the Charge Points chart draws below its sessions The sessions and companion graph rules move into helpers in belief_charts.py, so that a registry of chart types can describe the same layout. A lowercase "power flow by type" graph is now restricted to the charge points power, as a lowercase "prices" graph was already drawn, and the builder no longer edits the sensors_to_show it is given. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/conftest.py | 86 ++++++++++++++ .../data/models/charts/belief_charts.py | 106 ++++++++++++------ .../tests/test_asset_chart_types_fresh_db.py | 96 ++++++++++++++++ 3 files changed, 255 insertions(+), 33 deletions(-) create mode 100644 flexmeasures/data/tests/test_asset_chart_types_fresh_db.py diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 03da927b0e..d63c01fcc5 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -4,6 +4,7 @@ import builtins import warnings import pytest +import pytz from random import random, seed from datetime import datetime, timedelta from sqlalchemy import Integer, delete, func, or_, select, text @@ -2263,3 +2264,88 @@ def import_hook(name, *args, **kwargs): setattr(module, "server_now", original_server_now) except Exception: pass + + +@pytest.fixture +def charge_hub(fresh_db) -> dict[str, Sensor]: + """A site with a charge point, whose dashboard has the graphs a site with charge points gets. + + One belief per sensor (on 1 January 2015), so 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. + + :returns: the site's sensors, by name. + """ + site_type = GenericAssetType(name="test_site_type_for_chart_sensors") + charge_point_type = GenericAssetType(name="smartbuilding.chargepoint") + fresh_db.session.add_all([site_type, charge_point_type]) + site = GenericAsset( + name="Test charge hub (chart sensors)", generic_asset_type=site_type + ) + fresh_db.session.add(site) + fresh_db.session.add( + GenericAsset( + name="Test charge point (chart sensors)", + generic_asset_type=charge_point_type, + parent_asset=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=datetime(2015, 1, 1, tzinfo=pytz.utc), + event_value=1.0, + belief_horizon=timedelta(0), + source=source, + ) + ) + fresh_db.session.commit() + return sensors diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index fb1cff0449..6f67c99c55 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -17,6 +17,70 @@ 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", +) +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" + + +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 create_bar_chart_or_histogram_specs( sensor: "Sensor", # noqa F821 @@ -769,7 +833,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) @@ -1155,31 +1219,12 @@ 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 [ - "arrival", - "departure", - "start charging", - "stop charging", - "plug in", - "plug out", - ] - ] - ) + all_sensors = _charge_point_session_sensors(sensors_to_show) 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": { @@ -1451,16 +1496,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": - for plot in entry.get("plots", []): - if "sensors" in plot: - plot["sensors"] = [ - sensor - for sensor in plot["sensors"] - if sensor.name == "charge points power" - ] + # 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, @@ -1471,7 +1511,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/tests/test_asset_chart_types_fresh_db.py b/flexmeasures/data/tests/test_asset_chart_types_fresh_db.py new file mode 100644 index 0000000000..47f1c7aa9e --- /dev/null +++ b/flexmeasures/data/tests/test_asset_chart_types_fresh_db.py @@ -0,0 +1,96 @@ +"""Tests for the asset chart types: what each one draws (issue #2795).""" + +from __future__ import annotations + +import pytest + +from flexmeasures import Sensor +from flexmeasures.data.models.charts.belief_charts import ( + chart_for_chargepoint_sessions, +) + + +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 + + +def _retitle(fresh_db, site, titles: dict[str, str]): + """Rename graphs in the site's sensors_to_show.""" + site.sensors_to_show = [ + {**entry, "title": titles.get(entry["title"], entry["title"])} + for entry in site.sensors_to_show + ] + fresh_db.session.commit() + + +@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_draws_its_companion_graphs_whatever_the_title_case( + fresh_db, charge_hub: dict[str, Sensor], prices_title, power_flow_title +): + """The Charge Points chart capitalizes graph titles, so a lowercase companion title still counts, and is restricted the same way.""" + site = charge_hub["arrival"].generic_asset + _retitle( + fresh_db, site, {"Prices": prices_title, "Power flow by type": power_flow_title} + ) + + chart_specs = site.chart(chart_type="chart_for_chargepoint_sessions") + + assert [graph["title"] for graph in chart_specs["vconcat"]] == [ + "Charge Point sessions", + "Power flow by type", + "Prices", + ] + # Of the power flows, only the charge points power is drawn. + assert _sensor_ids_drawn(chart_specs) == { + charge_hub[name].id + for name in ( + "arrival", + "departure", + "plug in", + "charge points power", + "consumption price", + ) + } + + +def test_charge_points_chart_leaves_the_sensors_to_show_it_is_given_alone( + charge_hub: dict[str, Sensor], +): + site = charge_hub["arrival"].generic_asset + sensors_to_show = site.validate_sensors_to_show() + # As GenericAsset.chart does before calling a chart builder. + for sensor in charge_hub.values(): + sensor.sensor_type = sensor.name + before = [ + [plot.get("sensors", plot.get("sensor")) for plot in entry["plots"]] + for entry in sensors_to_show + ] + + chart_for_chargepoint_sessions(sensors_to_show) + + assert [ + [plot.get("sensors", plot.get("sensor")) for plot in entry["plots"]] + for entry in sensors_to_show + ] == before From 781e40bd5e61e498ae2fe3709c8b521c1adb91d0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 15:23:49 +0100 Subject: [PATCH 02/26] Add a registry of asset chart types, and draw asset charts through it The registry in flexmeasures/data/models/charts/chart_types.py is the one place that lists the chart types of an asset: "default" and "charge-point-sessions". Each entry has a public, kebab-case name, the assets it applies to, its layout (the graphs it draws, with their sensors) and its Vega-Lite builder. GenericAsset.chart looks its chart type up there, which answers the todo in its docstring, and still accepts the builder names as legacy names. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- .../data/models/charts/chart_types.py | 155 ++++++++++++++++++ flexmeasures/data/models/charts/readme.md | 3 + .../data/models/charts/test_chart_types.py | 39 +++++ flexmeasures/data/models/generic_assets.py | 11 +- .../tests/test_asset_chart_types_fresh_db.py | 117 ++++++++++++- 5 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 flexmeasures/data/models/charts/chart_types.py create mode 100644 flexmeasures/data/models/charts/test_chart_types.py diff --git a/flexmeasures/data/models/charts/chart_types.py b/flexmeasures/data/models/charts/chart_types.py new file mode 100644 index 0000000000..0c5cb9092f --- /dev/null +++ b/flexmeasures/data/models/charts/chart_types.py @@ -0,0 +1,155 @@ +"""The registry of asset chart types: the one place that lists the charts an asset can be drawn as. + +Each chart type has a public, kebab-case name, which API users pass as ``chart-type``. +It also says which assets it applies to, which graphs it draws (its layout), and which function builds its Vega-Lite specs. + +A layout is a list of graphs, each a dictionary in the format of a validated ``sensors_to_show`` entry: +a ``title`` and a list of ``plots`` holding the graph's sensors. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from flexmeasures.data.models.charts.belief_charts import ( + CHARGE_POINT_SESSIONS_TITLE, + _charge_point_session_sensors, + charge_point_companion_graph, + chart_for_chargepoint_sessions, + chart_for_multiple_sensors, +) +from flexmeasures.data.models.charts.defaults import apply_chart_defaults + +# The asset type of the charge points whose sessions the Charge Points chart shows. +CHARGE_POINT_ASSET_TYPE_NAME = "smartbuilding.chargepoint" + + +class UnknownAssetChartType(ValueError): + """Raised for a name that is not in the registry of asset chart types.""" + + +@dataclass(frozen=True) +class AssetChartType: + """A chart an asset can be drawn as. + + :param name: the public, kebab-case name, as API users pass it in ``chart-type``. + :param label: the name shown in the UI. + :param description: what the chart shows, for the API documentation. + :param applies_to: whether the chart type is meant for this asset. + :param layout: the graphs the chart draws, given the asset's validated ``sensors_to_show``. + :param builder: the function that builds the chart's Vega-Lite specs from the asset's validated ``sensors_to_show``. + :param legacy_name: the name of the builder, which the chart endpoint took before the chart types had public names. + """ + + name: str + label: str + description: str + applies_to: Callable[["GenericAsset"], bool] # noqa F821 + layout: Callable[[list[dict]], list[dict]] + builder: Callable[..., dict] + legacy_name: str | None = None + + def chart_specs(self, **kwargs) -> dict: + """Build the chart's Vega-Lite specs, with the FlexMeasures defaults for settings like width and height.""" + return apply_chart_defaults(self.builder)(**kwargs) + + def sensors(self, sensors_to_show: list[dict]) -> list["Sensor"]: # noqa F821 + """The sensors whose data the chart draws, in the order of its layout. + + :param sensors_to_show: the asset's validated sensors_to_show. + :returns: a unique list of sensors. + """ + from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema + + return SensorsToShowSchema.flatten(self.layout(sensors_to_show)) + + +def default_layout(sensors_to_show: list[dict]) -> list[dict]: + """A graph per entry in the asset's sensors_to_show, except the graph of charging-session timestamps. + + Those timestamps are not plottable quantities, so only the Charge Points chart draws them. + """ + return [ + entry + for entry in sensors_to_show + if entry.get("title") != CHARGE_POINT_SESSIONS_TITLE + ] + + +def charge_point_sessions_layout(sensors_to_show: list[dict]) -> list[dict]: + """The charging sessions, followed by the graphs drawn below them. + + The sessions graph holds the charging-session timestamps found in any graph. + Below it come the Prices graph and, of the Power flow by type graph, the charge points power (see charge_point_companion_graph). + """ + sessions_graph = { + "title": CHARGE_POINT_SESSIONS_TITLE, + "plots": [{"sensors": _charge_point_session_sensors(sensors_to_show)}], + } + companion_graphs = [ + graph + for entry in sensors_to_show + if (graph := charge_point_companion_graph(entry)) is not None + ] + return [sessions_graph, *companion_graphs] + + +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( + child.generic_asset_type.name == CHARGE_POINT_ASSET_TYPE_NAME + for child in asset.child_assets + ) + + +DEFAULT_ASSET_CHART_TYPE = "default" + +ASSET_CHART_TYPES: dict[str, AssetChartType] = { + chart_type.name: chart_type + for chart_type in ( + AssetChartType( + name=DEFAULT_ASSET_CHART_TYPE, + label="Default", + description="a graph for each entry in the asset's sensors_to_show", + applies_to=lambda asset: True, + layout=default_layout, + builder=chart_for_multiple_sensors, + legacy_name="chart_for_multiple_sensors", + ), + AssetChartType( + name="charge-point-sessions", + label="Charge Points", + description="the charging sessions of the asset's charge points, with its prices and charge points power below them", + applies_to=has_charge_points, + layout=charge_point_sessions_layout, + builder=chart_for_chargepoint_sessions, + legacy_name="chart_for_chargepoint_sessions", + ), + ) +} + +# The names the chart endpoint took before the chart types had public names, mapped to those public names. +LEGACY_ASSET_CHART_TYPE_NAMES: dict[str, str] = { + chart_type.legacy_name: chart_type.name + for chart_type in ASSET_CHART_TYPES.values() + if chart_type.legacy_name +} + + +def get_asset_chart_type(name: str, accept_legacy_name: bool = True) -> AssetChartType: + """Look up an asset chart type by its name. + + :param name: the public name of the chart type, or (if accepted) its legacy name. + :param accept_legacy_name: whether to also accept the name of the chart type's builder. + :returns: the chart type. + :raises UnknownAssetChartType: if no chart type goes by that name. + """ + if accept_legacy_name: + name = LEGACY_ASSET_CHART_TYPE_NAMES.get(name, name) + try: + return ASSET_CHART_TYPES[name] + except KeyError: + raise UnknownAssetChartType( + f"Unknown asset chart type '{name}'; choose from {', '.join(ASSET_CHART_TYPES)}." + ) from None diff --git a/flexmeasures/data/models/charts/readme.md b/flexmeasures/data/models/charts/readme.md index f918ff80f5..4402571be7 100644 --- a/flexmeasures/data/models/charts/readme.md +++ b/flexmeasures/data/models/charts/readme.md @@ -5,3 +5,6 @@ Alternatively, they can be specified as a function that returns a dict (with veg This approach is useful if you need to parameterize the specification with kwargs. Todo: support a plug-in architecture, see https://packaging.python.org/guides/creating-and-discovering-plugins/ + +The chart types an asset chart can be drawn as are listed in `chart_types.py`, the registry of asset chart types. +To add one, add an entry there: its public, kebab-case name, the assets it applies to, its layout (the graphs it draws) and the function that builds its chart specs. diff --git a/flexmeasures/data/models/charts/test_chart_types.py b/flexmeasures/data/models/charts/test_chart_types.py new file mode 100644 index 0000000000..9f267054eb --- /dev/null +++ b/flexmeasures/data/models/charts/test_chart_types.py @@ -0,0 +1,39 @@ +"""Tests for the registry of asset chart types (issue #2795).""" + +import pytest + +from flexmeasures.data.models.charts.chart_types import ( + ASSET_CHART_TYPES, + DEFAULT_ASSET_CHART_TYPE, + LEGACY_ASSET_CHART_TYPE_NAMES, + UnknownAssetChartType, + get_asset_chart_type, +) + + +def test_the_registry_lists_the_asset_chart_types(): + assert list(ASSET_CHART_TYPES) == ["default", "charge-point-sessions"] + assert DEFAULT_ASSET_CHART_TYPE == "default" + for name, chart_type in ASSET_CHART_TYPES.items(): + assert chart_type.name == name + + +def test_the_legacy_names_are_the_names_of_the_chart_builders(): + assert LEGACY_ASSET_CHART_TYPE_NAMES == { + "chart_for_multiple_sensors": "default", + "chart_for_chargepoint_sessions": "charge-point-sessions", + } + for legacy_name, name in LEGACY_ASSET_CHART_TYPE_NAMES.items(): + assert ASSET_CHART_TYPES[name].builder.__name__ == legacy_name + + +def test_looking_up_a_chart_type(): + assert get_asset_chart_type("charge-point-sessions").name == "charge-point-sessions" + assert ( + get_asset_chart_type("chart_for_chargepoint_sessions").name + == "charge-point-sessions" + ) + with pytest.raises(UnknownAssetChartType): + get_asset_chart_type("chart_for_chargepoint_sessions", accept_legacy_name=False) + with pytest.raises(UnknownAssetChartType, match="default, charge-point-sessions"): + get_asset_chart_type("daily_heatmap") diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 0acdf4d553..bb678701da 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -17,7 +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 import chart_type_to_chart_specs +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 @@ -990,7 +990,7 @@ def count_annotations( def chart( self, - chart_type: str = "chart_for_multiple_sensors", + chart_type: str = "default", event_starts_after: datetime | None = None, event_ends_before: datetime | None = None, beliefs_after: datetime | None = None, @@ -1008,7 +1008,8 @@ def chart( ) -> dict: """Create a vega-lite chart showing sensor data. - :param chart_type: currently only "chart_for_multiple_sensors" # todo: where can we properly list the available chart types? + :param chart_type: name of the chart type, as listed in the registry of asset chart types (ASSET_CHART_TYPES in flexmeasures.data.models.charts.chart_types); + the legacy names "chart_for_multiple_sensors" and "chart_for_chargepoint_sessions" are still accepted. :param event_starts_after: only return beliefs about events that start after this datetime (inclusive) :param event_ends_before: only return beliefs about events that end before this datetime (inclusive) :param beliefs_after: only return beliefs formed after this datetime (inclusive) @@ -1021,6 +1022,7 @@ def chart( :param dataset_name: optionally name the dataset used in the chart (the default name is sensor_) :param resolution: optionally set the resolution of data being displayed :returns: JSON string defining vega-lite chart specs + :raises UnknownAssetChartType: if no asset chart type goes by the given name """ from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema @@ -1037,8 +1039,7 @@ def chart( kwargs["event_starts_after"] = event_starts_after if event_ends_before: kwargs["event_ends_before"] = event_ends_before - chart_specs = chart_type_to_chart_specs( - chart_type, + chart_specs = get_asset_chart_type(chart_type).chart_specs( sensors_to_show=processed_sensors_to_show, dataset_name=dataset_name, combine_legend=combine_legend, diff --git a/flexmeasures/data/tests/test_asset_chart_types_fresh_db.py b/flexmeasures/data/tests/test_asset_chart_types_fresh_db.py index 47f1c7aa9e..f10668e138 100644 --- a/flexmeasures/data/tests/test_asset_chart_types_fresh_db.py +++ b/flexmeasures/data/tests/test_asset_chart_types_fresh_db.py @@ -8,6 +8,12 @@ from flexmeasures.data.models.charts.belief_charts import ( chart_for_chargepoint_sessions, ) +from flexmeasures.data.models.charts.chart_types import ( + ASSET_CHART_TYPES, + UnknownAssetChartType, + get_asset_chart_type, +) +from flexmeasures.utils.flexmeasures_inflection import capitalize def _sensor_ids_drawn(chart_specs: dict | list) -> set[int]: @@ -55,7 +61,7 @@ def test_charge_points_chart_draws_its_companion_graphs_whatever_the_title_case( fresh_db, site, {"Prices": prices_title, "Power flow by type": power_flow_title} ) - chart_specs = site.chart(chart_type="chart_for_chargepoint_sessions") + chart_specs = site.chart(chart_type="charge-point-sessions") assert [graph["title"] for graph in chart_specs["vconcat"]] == [ "Charge Point sessions", @@ -94,3 +100,112 @@ def test_charge_points_chart_leaves_the_sensors_to_show_it_is_given_alone( [plot.get("sensors", plot.get("sensor")) for plot in entry["plots"]] for entry in sensors_to_show ] == before + + +@pytest.mark.parametrize( + "chart_type, titles", + [ + ("default", {}), + ("default", {"Prices": "prices", "Weather": "weather"}), + ("charge-point-sessions", {}), + ( + "charge-point-sessions", + {"Prices": "prices", "Power flow by type": "power flow by type"}, + ), + ], +) +def test_each_chart_type_draws_the_graphs_and_sensors_of_its_layout( + fresh_db, charge_hub: dict[str, Sensor], chart_type, titles +): + """The registry's layout of a chart type is what its Vega-Lite chart draws, graph by graph.""" + site = charge_hub["arrival"].generic_asset + _retitle(fresh_db, site, titles) + registered = get_asset_chart_type(chart_type) + layout = registered.layout(site.validate_sensors_to_show()) + + chart_specs = site.chart(chart_type=chart_type) + + assert [graph["title"] for graph in chart_specs["vconcat"]] == [ + capitalize(graph["title"]) for graph in layout + ] + for graph, drawn in zip(layout, chart_specs["vconcat"]): + assert _sensor_ids_drawn(drawn) == { + sensor.id for sensor in registered.sensors([graph]) + }, graph["title"] + + +@pytest.mark.parametrize( + "chart_type, expected_names", + [ + ( + "default", + [ + "charge points power", + "building power", + "consumption price", + "temperature", + # Also in a graph of its own, which the default chart draws. + "departure", + ], + ), + ( + "charge-point-sessions", + [ + # In the order of the graphs they are found in. + "departure", + "arrival", + "plug in", + "charge points power", + "consumption price", + ], + ), + ], +) +def test_chart_type_sensors(charge_hub: dict[str, Sensor], chart_type, expected_names): + site = charge_hub["arrival"].generic_asset + sensors = get_asset_chart_type(chart_type).sensors(site.validate_sensors_to_show()) + assert [sensor.name for sensor in sensors] == expected_names + + +@pytest.mark.parametrize( + "legacy_name, name", + [ + ("chart_for_multiple_sensors", "default"), + ("chart_for_chargepoint_sessions", "charge-point-sessions"), + ], +) +def test_a_legacy_chart_type_name_draws_the_same_chart( + charge_hub: dict[str, Sensor], legacy_name, name +): + site = charge_hub["arrival"].generic_asset + assert site.chart(chart_type=legacy_name) == site.chart(chart_type=name) + + +def test_without_a_chart_type_an_asset_gets_the_default_chart( + charge_hub: dict[str, Sensor], +): + site = charge_hub["arrival"].generic_asset + assert site.chart() == site.chart(chart_type="default") + + +def test_an_unknown_chart_type_is_refused(charge_hub: dict[str, Sensor]): + site = charge_hub["arrival"].generic_asset + with pytest.raises(UnknownAssetChartType, match="charge-point-sessions"): + site.chart(chart_type="bar_chart") + + +def test_the_charge_points_chart_applies_to_assets_with_a_charge_point( + charge_hub: dict[str, Sensor], +): + site = charge_hub["arrival"].generic_asset + (charge_point,) = site.child_assets + assert [ + name + for name, chart_type in ASSET_CHART_TYPES.items() + if chart_type.applies_to(site) + ] == ["default", "charge-point-sessions"] + assert [ + name + for name, chart_type in ASSET_CHART_TYPES.items() + if chart_type.applies_to(charge_point) + ] == ["default"] From 79c557ac3352f00978b815981c34ffd99760a721 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 15:26:37 +0100 Subject: [PATCH 03/26] Validate chart-type on an asset's chart endpoint against the registry of chart types GET /api/v3_0/assets//chart now refuses an unknown chart-type with a 422, and lists the registry's names (default, charge-point-sessions) in its documentation. The builder names it took so far are still accepted, as legacy values. Without chart-type, it draws the default chart, as before. 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 | 13 +++- .../tests/test_asset_chart_types_fresh_db.py | 77 +++++++++++++++++++ flexmeasures/data/schemas/charts.py | 54 +++++++++++++ flexmeasures/ui/static/openapi-specs.json | 10 ++- 4 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 flexmeasures/api/v3_0/tests/test_asset_chart_types_fresh_db.py create mode 100644 flexmeasures/data/schemas/charts.py diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index cdbe5ef5af..4bf7bf7098 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -91,6 +91,11 @@ GenericAssetTypeSchema as AssetTypeSchema, SensorsToShowSchema, ) +from flexmeasures.data.models.charts.chart_types import DEFAULT_ASSET_CHART_TYPE +from flexmeasures.data.schemas.charts import ( + AssetChartTypeField, + describe_asset_chart_types, +) from flexmeasures.data.schemas.scheduling import AssetTriggerSchema from flexmeasures.data.schemas.utils import SupportsLegacyFieldAliases from flexmeasures.data.services.scheduling import ( @@ -241,11 +246,15 @@ class AssetChartKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): description="Chart width in pixels; without it, the chart is scaled to the full width of its container.", ), ) - chart_type = fields.Str( + chart_type = AssetChartTypeField( data_key="chart-type", required=False, + load_default=DEFAULT_ASSET_CHART_TYPE, + accept_legacy_names=True, metadata=dict( - description="Chart type, e.g. 'bar_chart' or 'daily_heatmap'.", + description=f"Which chart to draw (default: '{DEFAULT_ASSET_CHART_TYPE}'): " + f"{describe_asset_chart_types(include_legacy_names=True)}.", + example=DEFAULT_ASSET_CHART_TYPE, ), ) diff --git a/flexmeasures/api/v3_0/tests/test_asset_chart_types_fresh_db.py b/flexmeasures/api/v3_0/tests/test_asset_chart_types_fresh_db.py new file mode 100644 index 0000000000..97a086332f --- /dev/null +++ b/flexmeasures/api/v3_0/tests/test_asset_chart_types_fresh_db.py @@ -0,0 +1,77 @@ +"""Tests for the chart-type of an asset chart: the registry's names, the legacy names, and unknown names (issue #2795).""" + +from __future__ import annotations + +import json + +import pytest +from flask import url_for + +from flexmeasures import Sensor + + +def _get_chart(client, site, query: dict): + return client.get(url_for("AssetAPI:get_chart", id=site.id), query_string=query) + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +@pytest.mark.parametrize( + "legacy_name, name", + [ + ("chart_for_multiple_sensors", "default"), + ("chart_for_chargepoint_sessions", "charge-point-sessions"), + ], +) +def test_chart_accepts_the_new_and_the_legacy_chart_type_names( + client, + setup_roles_users_fresh_db, + charge_hub: dict[str, Sensor], + requesting_user, + legacy_name, + name, +): + site = charge_hub["arrival"].generic_asset + response = _get_chart(client, site, {"chart-type": name}) + legacy_response = _get_chart(client, site, {"chart-type": legacy_name}) + assert response.status_code == 200, response.data + assert legacy_response.status_code == 200, legacy_response.data + chart_specs = json.loads(response.data) + assert json.loads(legacy_response.data) == chart_specs + # The two chart types draw different charts, so the comparison above means something. + assert ( + "Charge Point sessions" in [graph["title"] for graph in chart_specs["vconcat"]] + ) == (name == "charge-point-sessions") + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +def test_chart_draws_the_default_chart_without_a_chart_type( + client, setup_roles_users_fresh_db, charge_hub: dict[str, Sensor], requesting_user +): + site = charge_hub["arrival"].generic_asset + response = _get_chart(client, site, {}) + assert response.status_code == 200, response.data + assert ( + json.loads(response.data) + == json.loads(_get_chart(client, site, {"chart-type": "default"}).data) + != json.loads( + _get_chart(client, site, {"chart-type": "charge-point-sessions"}).data + ) + ) + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +@pytest.mark.parametrize( + "chart_type", + ["bar_chart", "daily_heatmap", "chart_for_chargepoint_session", "Default", ""], +) +def test_chart_refuses_an_unknown_chart_type( + client, + setup_roles_users_fresh_db, + charge_hub: dict[str, Sensor], + requesting_user, + chart_type, +): + site = charge_hub["arrival"].generic_asset + response = _get_chart(client, site, {"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/schemas/charts.py b/flexmeasures/data/schemas/charts.py new file mode 100644 index 0000000000..26e64dd492 --- /dev/null +++ b/flexmeasures/data/schemas/charts.py @@ -0,0 +1,54 @@ +"""Fields for choosing which chart to draw.""" + +from __future__ import annotations + +from marshmallow import fields, validate + +from flexmeasures.data.models.charts.chart_types import ( + ASSET_CHART_TYPES, + DEFAULT_ASSET_CHART_TYPE, + LEGACY_ASSET_CHART_TYPE_NAMES, +) + + +def describe_asset_chart_types(include_legacy_names: bool = False) -> str: + """List the asset chart types from the registry, for a field description. + + :param include_legacy_names: whether to also mention the legacy names that are still accepted. + """ + description = "; ".join( + f"'{name}' ({chart_type.description})" + for name, chart_type in ASSET_CHART_TYPES.items() + ) + if include_legacy_names: + legacy_names = " and ".join( + f"'{legacy_name}' (for '{name}')" + for legacy_name, name in LEGACY_ASSET_CHART_TYPE_NAMES.items() + ) + description += f". The legacy values {legacy_names} are still accepted" + return description + + +class AssetChartTypeField(fields.Str): + """The name of an asset chart type, validated against the registry of asset chart types. + + An unknown name fails validation, which the API answers with a 422. + """ + + def __init__(self, accept_legacy_names: bool = False, **kwargs): + """Set up the field. + + :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. + """ + self.accept_legacy_names = accept_legacy_names + kwargs["validate"] = validate.OneOf(list(ASSET_CHART_TYPES)) + metadata = kwargs.setdefault("metadata", {}) + metadata.setdefault("example", DEFAULT_ASSET_CHART_TYPE) + super().__init__(**kwargs) + + def _deserialize(self, value, attr, data, **kwargs) -> str: + name = super()._deserialize(value, attr, data, **kwargs) + if self.accept_legacy_names: + return LEGACY_ASSET_CHART_TYPE_NAMES.get(name, name) + return name diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 35621ee5f5..3d411257f6 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4037,9 +4037,15 @@ { "in": "query", "name": "chart-type", - "description": "Chart type, e.g. 'bar_chart' or 'daily_heatmap'.", + "description": "Which chart to draw (default: 'default'): 'default' (a graph for each entry in the asset's sensors_to_show); '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 still accepted.", "schema": { - "type": "string" + "type": "string", + "default": "default", + "enum": [ + "default", + "charge-point-sessions" + ], + "example": "default" }, "required": false } From 6a208e5094024d5c7d5babee4d2e27154dee3f4a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 2 Nov 2026 15:28:22 +0100 Subject: [PATCH 04/26] Let the asset page ask for its charts by their public chart type names The chart picker, graphs.html and the fast chart now use "default" and "charge-point-sessions", so the asset page no longer relies on the legacy names that the chart endpoint still accepts. 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 | 6 +-- .../ui/templates/assets/asset_graph.html | 8 ++-- .../ui/templates/includes/graphs.html | 8 ++-- flexmeasures/ui/tests/js/test_fast_chart.py | 42 +++++++++++++++++++ flexmeasures/ui/tests/test_asset_crud.py | 22 ++++++++++ 5 files changed, 75 insertions(+), 11 deletions(-) diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index b0a2391aa6..468c5509ee 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2388,7 +2388,7 @@ export function buildChargePointSessionsOption(elementId, data, opts) { * @param {Object} [options] - { groupSpec, chartType, legendsBelow, datasetName, * fullBeliefInfo }. * chartType: "line" (default), "bar_chart", "histogram", "daily_heatmap", - * "weekly_heatmap" or "chart_for_chargepoint_sessions". + * "weekly_heatmap" or "charge-point-sessions". */ export function renderFastChart(elementId, data, options) { const container = document.getElementById(elementId); @@ -2440,7 +2440,7 @@ export function renderFastChart(elementId, data, options) { option = buildHistogramOption(elementId, data, opts); } else if (opts.chartType === "daily_heatmap" || opts.chartType === "weekly_heatmap") { option = buildHeatmapOption(elementId, data, opts); - } else if (opts.chartType === "chart_for_chargepoint_sessions") { + } else if (opts.chartType === "charge-point-sessions") { option = buildChargePointSessionsOption(elementId, data, opts); } else { const groupSpec = (opts.groupSpec || []).filter((g) => g.title !== CHARGE_POINT_SESSIONS_GROUP_TITLE); @@ -2846,7 +2846,7 @@ function wireSessionTooltipRedirect(instance, opts) { chart.off("mouseover", instance.onSessionHitHover); instance.onSessionHitHover = null; } - if (opts.chartType !== "chart_for_chargepoint_sessions") { + if (opts.chartType !== "charge-point-sessions") { return; } instance.onSessionHitHover = (ev) => { diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index b829c87d62..92148f27eb 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -49,10 +49,10 @@ Select chart