diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 04401f0158..98e27b606c 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -8,6 +8,8 @@ API change log v3.0-35 | XX, 2026 """""""""""""""""" - ``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``. 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-34 | November 05, 2026 """""""""""""""""""""""""""" diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 35eb7117ae..72d54286c7 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -10,6 +10,7 @@ v1.2.0 | December 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 #2805 `_] Infrastructure / Support ------------------------- diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 86da34318a..f40eaf85ad 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,15 +290,15 @@ 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): - # it takes the public chart type names the asset page sends, and the names of the chart functions it sent in v1.1.0, - # but is not yet validated against the registry of asset chart types (see issue #2802). - 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. 'default' or 'charge-point-sessions'; without it, return the data of every sensor in the asset's sensors_to_show.", - example="default", + 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, ), ) @@ -329,16 +330,10 @@ def __init__(self, *args, **kwargs): class AssetChartDataOpenAPISchema(AssetChartDataKwargsSchema): - """Doc-only variant of `AssetChartDataKwargsSchema`; see `AssetChartOpenAPISchema`. - - It also hides `chart_type`, which only the asset page uses for now. - """ + """Doc-only variant of `AssetChartDataKwargsSchema`; see `AssetChartOpenAPISchema`.""" def __init__(self, *args, **kwargs): - kwargs["exclude"] = list(kwargs.get("exclude", [])) + [ - "beliefs_after", - "chart_type", - ] + kwargs["exclude"] = list(kwargs.get("exclude", [])) + ["beliefs_after"] 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 1a9fddace2..f2e556da0b 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,103 +109,140 @@ 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", - }, - ), - # The public names, which the asset page sends since the registry of asset chart types (issue #2795). - ( - "default", - { - "charge points power", - "building power", - "consumption price", - "temperature", - "departure", - }, - ), - ( - "charge-point-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) + + +@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} -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 it is validated against the registry of asset chart types (issue #2802).""" +def test_chart_type_is_in_the_chart_data_docs(): + """Since chart-type on chart_data takes the registry's public chart type names, the published specs document it (issue #2802).""" 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 + chart_type = next( + parameter for parameter in parameters if parameter["name"] == "chart-type" + ) + assert "charge-point-sessions" in chart_type["description"] diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index 88929a4ba3..0ce78a24eb 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( @@ -92,27 +85,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/charts/chart_types.py b/flexmeasures/data/models/charts/chart_types.py index dd10fc5e90..83605e6f0f 100644 --- a/flexmeasures/data/models/charts/chart_types.py +++ b/flexmeasures/data/models/charts/chart_types.py @@ -14,7 +14,10 @@ from typing import Callable, Generic, TypeVar 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, bar_chart, charge_point_session_sensors, charge_point_companion_graph, @@ -204,6 +207,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/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index edb440b659..bea44d95a0 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, @@ -1077,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, @@ -1087,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, diff --git a/flexmeasures/data/schemas/charts.py b/flexmeasures/data/schemas/charts.py index c7f421b517..697e423720 100644 --- a/flexmeasures/data/schemas/charts.py +++ b/flexmeasures/data/schemas/charts.py @@ -107,7 +107,12 @@ class ChartTypeField(fields.Str): registry: ChartTypeRegistry default_chart_type: str - 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. @@ -115,8 +120,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] @@ -128,6 +135,11 @@ def __init__(self, accept_legacy_names: bool = False, **kwargs): metadata.setdefault("example", self.default_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/__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 94da0c9b84..3feb31d840 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -2152,7 +2152,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. @@ -2170,15 +2170,31 @@ 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) { 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; @@ -2471,7 +2487,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/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 0d987ac0a1..5bf49294c8 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4264,6 +4264,20 @@ "type": "boolean" }, "required": false + }, + { + "in": "query", + "name": "chart-type", + "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", + "enum": [ + "default", + "charge-point-sessions" + ], + "example": "default" + }, + "required": false } ], "responses": { diff --git a/flexmeasures/ui/templates/includes/graphs.html b/flexmeasures/ui/templates/includes/graphs.html index dcd013c3dc..8aaec504b2 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_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]); """) diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index dcf9864cf4..a352f26afb 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -2,7 +2,9 @@ import json -from flexmeasures.data.models.charts.belief_charts import CHARGE_POINT_CHART_NAMES +import pytest + +from flexmeasures.data.models.charts.chart_types import CHARGE_POINT_CHART_NAMES def test_visible_time_range_tracks_mouse_zoom(assert_js): @@ -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): @@ -744,3 +746,101 @@ def test_charge_point_companion_titles_match_whatever_the_case_of_their_first_le eq(`"${title}" is not a companion graph`, isChargePointCompanionTitle(title, names), false); } """.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 +): + """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))