diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 04f577a87d..04401f0158 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -5,6 +5,10 @@ API change log .. note:: The FlexMeasures API follows its own versioning scheme. This is also reflected in the URL (e.g. `/api/v3_0`), allowing developers to upgrade at their own pace. +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. + v3.0-34 | November 05, 2026 """""""""""""""""""""""""""" diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4ea6d1d02c..35eb7117ae 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -9,12 +9,14 @@ 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 `_] Infrastructure / Support ------------------------- Bugfixes ----------- +* 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 #2803 `_] v1.1.0 | November 05, 2026 diff --git a/documentation/plugin/customisation.rst b/documentation/plugin/customisation.rst index b1ab5f2030..dea7234b39 100644 --- a/documentation/plugin/customisation.rst +++ b/documentation/plugin/customisation.rst @@ -455,3 +455,67 @@ Registration belongs to each Flask application, allowing separate applications t Create an automation using the CLI example in :ref:`automations`. Run a worker for the handler's queue and invoke ``flexmeasures jobs run-automation --automation ID`` to verify one run locally before activating its recurrence. The execution user's permissions and the output sensors' asset scope are checked again when queueing and computing a run. + + +Adding your own chart types +--------------------------- + +An asset's graphs can be drawn as different chart types, which the asset page offers in a picker, and which the chart API takes as ``chart-type``. +A plugin can add its own chart type by registering it when the plugin is set up, for instance in its ``__init__.py``: + +.. code-block:: python + + from flexmeasures.data.models.charts.chart_types import AssetChartType, register_asset_chart_type + + + def heat_pumps_layout(sensors_to_show: list[dict]) -> list[dict]: + """The graphs to draw (and whose sensor data to load), from the asset's validated sensors_to_show.""" + return [graph for graph in sensors_to_show if graph.get("title") == "Heat pumps"] + + + def chart_for_heat_pumps(sensors_to_show: list[dict], **kwargs) -> dict: + """Return the Vega-Lite specs of the chart.""" + ... + + + register_asset_chart_type( + AssetChartType( + name="my-plugin-heat-pumps", + label="Heat pumps", + description="the heat pumps of the site", + applies_to=lambda asset: asset.generic_asset_type.name == "heat-pump-site", + layout=heat_pumps_layout, + builder=chart_for_heat_pumps, + ) + ) + +Prefix the name with your plugin's name, so it does not clash with chart types FlexMeasures may add later; a name that is already taken is refused. + +A sensor's data can likewise be drawn as different chart types, which the sensor page offers in a picker, and which the sensor chart endpoint (``/api/ui/sensor//chart``) takes as ``chart-type``. +The built-in ones are ``bar-chart``, ``histogram``, ``daily-heatmap`` and ``weekly-heatmap``. +A plugin registers its own sensor chart type in the same way: + +.. code-block:: python + + from flexmeasures.data.models.charts.chart_types import SensorChartType, register_sensor_chart_type + + + def duration_curve(sensor, event_starts_after=None, event_ends_before=None, **kwargs) -> dict: + """Return the Vega-Lite specs of the chart.""" + ... + + + register_sensor_chart_type( + SensorChartType( + name="my-plugin-duration-curve", + label="Duration curve", + description="the sensor's values, sorted from high to low", + applies_to=lambda sensor: sensor.unit in ("kW", "MW"), + builder=duration_curve, + ) + ) + +The sensor page offers it on the sensors it applies to. +The fast chart (the "Fast chart" toggle) only draws the built-in chart types, so the asset and sensor pages draw your chart types with Vega-Lite. + +.. note:: The fields of ``AssetChartType`` and ``SensorChartType`` are provisional, and may still change before plugins can rely on them. diff --git a/flexmeasures/api/ui/sensors.py b/flexmeasures/api/ui/sensors.py index c5e55d649e..a3517ca826 100644 --- a/flexmeasures/api/ui/sensors.py +++ b/flexmeasures/api/ui/sensors.py @@ -4,7 +4,7 @@ from flask import session from flask_classful import FlaskView, route from flask_security import current_user -from marshmallow import fields, validate +from marshmallow import fields from webargs.flaskparser import use_kwargs from werkzeug.exceptions import abort @@ -20,9 +20,12 @@ EventWindowSchema, BeliefTimeFilterSchema, ) -from flexmeasures.data.models.charts import SENSOR_CHART_TYPES from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.data.models.time_series import Sensor +from flexmeasures.data.schemas.charts import ( + SensorChartTypeField, + describe_sensor_chart_types, +) from flexmeasures.data.services.annotations import prepare_annotations_for_chart from flexmeasures.ui.utils.view_utils import set_session_variables @@ -74,13 +77,13 @@ class SensorChartKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): description="Name to use for the embedded chart dataset.", ), ) - chart_type = fields.Str( + chart_type = SensorChartTypeField( data_key="chart-type", required=False, - validate=validate.OneOf(list(SENSOR_CHART_TYPES)), + accept_legacy_names=True, metadata=dict( - description=f"Chart type, one of {', '.join(repr(name) for name in SENSOR_CHART_TYPES)}.", - example="bar_chart", + description=f"Which chart to draw: {describe_sensor_chart_types(include_legacy_names=True)}. " + "Plugins may register more chart types.", ), ) height = fields.Str( @@ -181,7 +184,7 @@ def get_chart(self, id: int, sensor: Sensor, **kwargs): - "duration" (ISO 8601 duration format; provide together with "start" or "end" to derive the other bound) - "prior" (legacy alias: "beliefs_before"; see the `timely-beliefs documentation `_) - "include-data" (legacy alias: "include_data"; if true, chart specs include the data; if false, use the `GET /api/ui/sensor/(id)/chart_data <../api/ui.html#get--api-ui-sensor-(id)-chart_data>`_ endpoint to fetch data) - - "chart-type" (legacy alias: "chart_type"; one of 'bar_chart', 'histogram', 'daily_heatmap' and 'weekly_heatmap', and anything else gets a 422) + - "chart-type" (legacy alias: "chart_type"; one of the registered sensor chart types: 'bar-chart' (the default), 'histogram', 'daily-heatmap', 'weekly-heatmap' and any that plugins register; the legacy values 'bar_chart', 'daily_heatmap' and 'weekly_heatmap' are still accepted, and anything else gets a 422) - "width" (an integer number of pixels; without it, the chart will be scaled to the full width of the container (hint: use ``
`` to set a div width to 100%) - "height" (an integer number of pixels; without it, FlexMeasures sets a default, currently 300) """ @@ -195,7 +198,8 @@ def get_chart(self, id: int, sensor: Sensor, **kwargs): }, ) # Store the validated chart type, rather than a raw request value: - # a request may send both spellings, and only the canonical one is validated. + # a request may send both spellings of the field, and only the canonical one is validated. + # A legacy chart type name is stored as the public name it stands for. if "chart_type" in kwargs: session["chart_type"] = kwargs["chart_type"] return json.dumps(sensor.chart(**kwargs)) diff --git a/flexmeasures/api/ui/tests/test_sensor_chart_types.py b/flexmeasures/api/ui/tests/test_sensor_chart_types.py index 38ff65df48..ccb1c681d3 100644 --- a/flexmeasures/api/ui/tests/test_sensor_chart_types.py +++ b/flexmeasures/api/ui/tests/test_sensor_chart_types.py @@ -9,13 +9,13 @@ from sqlalchemy import select from flexmeasures.data.models.charts import ( - ASSET_CHART_BUILDERS, CHART_TYPES, SENSOR_CHART_TYPES, UnknownChartType, chart_type_to_chart_specs, defaults, ) +from flexmeasures.data.models.charts.chart_types import LEGACY_SENSOR_CHART_TYPE_NAMES from flexmeasures.data.models.time_series import Sensor # Module-level chart specs, which building a chart must leave as they are. @@ -90,12 +90,13 @@ def test_the_session_keeps_only_a_validated_chart_type( ) assert response.status_code == 200, response.data with client.session_transaction() as session: - assert session["chart_type"] == "bar_chart" + # The legacy name is stored as the public name it stands for. + assert session["chart_type"] == "bar-chart" # The sensor page and its own chart request keep working. page = client.get(f"/sensors/{sensor.id}") assert page.status_code == 200 - assert "let chartType = 'bar_chart';" in page.data.decode() + assert "let chartType = 'bar-chart';" in page.data.decode() response = client.get(url, query_string={**WINDOW, "chart_type": "bar_chart"}) assert response.status_code == 200, response.data @@ -151,7 +152,7 @@ def test_building_a_chart_leaves_the_module_level_specs_alone(db, module_specs): assert getattr(defaults, name) == module_specs[name], name -@pytest.mark.parametrize("heatmap", ["daily_heatmap", "weekly_heatmap"]) +@pytest.mark.parametrize("heatmap", ["daily-heatmap", "weekly-heatmap"]) def test_a_heatmap_leaves_the_legend_of_later_charts_alone( setup_api_test_data, db, module_specs, heatmap ): @@ -162,7 +163,7 @@ def test_a_heatmap_leaves_the_legend_of_later_charts_alone( heatmap, sensor=sensor, dataset_name="sensor", include_annotations=False ) bar_chart = chart_type_to_chart_specs( - "bar_chart", sensor=sensor, dataset_name="sensor", include_annotations=False + "bar-chart", sensor=sensor, dataset_name="sensor", include_annotations=False ) assert bar_chart["config"]["legend"]["orient"] == "bottom" assert defaults.LEGIBILITY_DEFAULTS == module_specs["LEGIBILITY_DEFAULTS"] @@ -176,15 +177,12 @@ def test_chart_type_to_chart_specs_builds_only_chart_types(chart_type): def test_the_chart_types(): assert list(SENSOR_CHART_TYPES) == [ - "bar_chart", + "bar-chart", "histogram", - "daily_heatmap", - "weekly_heatmap", + "daily-heatmap", + "weekly-heatmap", ] - assert set(CHART_TYPES) == set(SENSOR_CHART_TYPES) | { - "chart_for_multiple_sensors", - "chart_for_chargepoint_sessions", - } + assert CHART_TYPES is SENSOR_CHART_TYPES def test_chart_defaults_are_applied_to_a_copy_of_the_specs(module_specs): @@ -195,8 +193,8 @@ def test_chart_defaults_are_applied_to_a_copy_of_the_specs(module_specs): assert defaults.REPLAY_RULER == module_specs["REPLAY_RULER"] -def test_the_api_docs_name_no_asset_chart_types(): - """The asset chart's chart-type is checked, but its current names are not published: public chart type names replace them in v1.2.0 (#2795).""" +def test_the_api_docs_name_the_public_asset_chart_types_only(): + """The asset chart's chart-type lists the public chart type names, and mentions the legacy names only as deprecated.""" from importlib.resources import files specs = json.loads( @@ -204,7 +202,95 @@ def test_the_api_docs_name_no_asset_chart_types(): ) parameters = specs["paths"]["/api/v3_0/assets/{id}/chart"]["get"]["parameters"] (chart_type,) = [p for p in parameters if p["name"] == "chart-type"] - assert "enum" not in chart_type["schema"] - documented = json.dumps(chart_type) - for name in ASSET_CHART_BUILDERS: - assert name not in documented + assert chart_type["schema"]["enum"] == ["default", "charge-point-sessions"] + legacy_names = ("chart_for_multiple_sensors", "chart_for_chargepoint_sessions") + for name in legacy_names: + assert name not in json.dumps(chart_type["schema"]) + for sentence in chart_type["description"].split(". "): + if any(name in sentence for name in legacy_names): + assert "deprecated" in sentence, sentence + + +@pytest.mark.parametrize( + "requesting_user", ["test_supplier_user_4@seita.nl"], indirect=True +) +@pytest.mark.parametrize( + "chart_type, public_name", + [ + *[ + (name, name) + for name in ("bar-chart", "histogram", "daily-heatmap", "weekly-heatmap") + ], + *LEGACY_SENSOR_CHART_TYPE_NAMES.items(), + ], +) +def test_sensor_chart_accepts_the_public_and_the_legacy_chart_type_names( + client, setup_api_test_data, requesting_user, db, chart_type, public_name +): + """A legacy name draws the same chart as its public name, which is what the session keeps.""" + sensor = _gas_sensor(db) + url = f"/api/ui/sensor/{sensor.id}/chart" + query = {**WINDOW, "dataset_name": f"sensor_{sensor.id}"} + + response = client.get(url, query_string={**query, "chart-type": chart_type}) + assert response.status_code == 200, response.data + with client.session_transaction() as session: + assert session["chart_type"] == public_name + + public = client.get(url, query_string={**query, "chart-type": public_name}) + assert json.loads(response.data) == json.loads(public.data) + + +@pytest.mark.parametrize( + "requesting_user", ["test_supplier_user_4@seita.nl"], indirect=True +) +@pytest.mark.parametrize("chart_type", ["default", "charge-point-sessions"]) +def test_sensor_chart_refuses_an_asset_chart_type( + client, setup_api_test_data, requesting_user, db, chart_type +): + sensor = _gas_sensor(db) + response = client.get( + f"/api/ui/sensor/{sensor.id}/chart", + query_string={**WINDOW, "chart-type": chart_type}, + ) + assert response.status_code == 422, response.data + + +@pytest.mark.parametrize( + "requesting_user", ["test_supplier_user_4@seita.nl"], indirect=True +) +@pytest.mark.parametrize( + "chart_type", ["test-plugin-sensor-chart", "test_plugin_sensor_chart"] +) +def test_sensor_chart_accepts_a_chart_type_registered_by_a_plugin( + client, + setup_api_test_data, + requesting_user, + db, + plugin_sensor_chart_type, + chart_type, +): + """A chart type a plugin registers after the app was created is drawn, and the session keeps its public name.""" + sensor = _gas_sensor(db) + url = f"/api/ui/sensor/{sensor.id}/chart" + query = {**WINDOW, "dataset_name": f"sensor_{sensor.id}"} + + response = client.get(url, query_string={**query, "chart-type": chart_type}) + + assert response.status_code == 200, response.data + with client.session_transaction() as session: + assert session["chart_type"] == "test-plugin-sensor-chart" + # The test plugin's chart type draws a bar chart. + bar_chart = client.get(url, query_string={**query, "chart-type": "bar-chart"}) + assert json.loads(response.data) == json.loads(bar_chart.data) + + +def test_chart_type_to_chart_specs_builds_a_chart_type_registered_by_a_plugin( + setup_api_test_data, db, plugin_sensor_chart_type +): + sensor = _gas_sensor(db) + sensor.sensor_type = sensor.name + kwargs = dict(sensor=sensor, dataset_name="sensor", include_annotations=False) + assert chart_type_to_chart_specs( + "test-plugin-sensor-chart", **kwargs + ) == chart_type_to_chart_specs("bar-chart", **kwargs) diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 05b194fb03..86da34318a 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -68,7 +68,6 @@ run_automation, update_automation, ) -from flexmeasures.data.models.charts import ASSET_CHART_BUILDERS from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType from flexmeasures.data.models.reporting import Reporter from flexmeasures.data.queries.generic_assets import ( @@ -91,6 +90,11 @@ GenericAssetIdField as AssetIdField, GenericAssetTypeSchema as AssetTypeSchema, ) +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 ( @@ -180,12 +184,6 @@ def __init__(self, *args, **kwargs): ) -def validate_asset_chart_type(chart_type: str): - """Refuse a chart type an asset cannot be drawn as.""" - if chart_type not in ASSET_CHART_BUILDERS: - raise ValidationError(f"Unknown chart type '{chart_type}'.") - - class AssetChartKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): legacy_field_aliases = { **EventWindowSchema.legacy_field_aliases, @@ -246,13 +244,16 @@ 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, - # Checked without OneOf, which would list the current names in the API docs: public chart type names replace them in v1.2.0 (#2795). - validate=validate_asset_chart_type, + load_default=DEFAULT_ASSET_CHART_TYPE, + accept_legacy_names=True, metadata=dict( - description="The chart to draw; leave it out for the asset's default chart.", + description=f"Which chart to draw (default: '{DEFAULT_ASSET_CHART_TYPE}'): " + f"{describe_asset_chart_types(include_legacy_names=True)}. " + "Plugins may register more chart types.", + example=DEFAULT_ASSET_CHART_TYPE, ), ) @@ -289,13 +290,14 @@ class AssetChartDataKwargsSchema(EventWindowSchema, BeliefTimeFilterSchema): ), ) # 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). + # 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( 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.", - example="chart_for_multiple_sensors", + 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", ), ) 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 cae4e71a86..1a9fddace2 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 @@ -225,6 +225,27 @@ def test_charge_points_chart_loads_what_it_draws_whatever_the_title_case( "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", + }, + ), ], ) def test_chart_data_holds_only_what_the_chart_type_draws( @@ -254,7 +275,7 @@ def test_chart_data_holds_only_what_the_chart_type_draws( 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).""" + """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).""" from importlib.resources import files specs = json.loads( 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..b8c14fdd09 --- /dev/null +++ b/flexmeasures/api/v3_0/tests/test_asset_chart_types_fresh_db.py @@ -0,0 +1,103 @@ +"""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) +# The asset page's Vega-Lite chart still sends the snake_case alias. +@pytest.mark.parametrize("parameter", ["chart-type", "chart_type"]) +@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_fresh_db: dict[str, Sensor], + requesting_user, + parameter, + legacy_name, + name, +): + site = charge_hub_fresh_db["arrival"].generic_asset + response = _get_chart(client, site, {parameter: name}) + legacy_response = _get_chart(client, site, {parameter: 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_fresh_db: dict[str, Sensor], + requesting_user, +): + site = charge_hub_fresh_db["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_fresh_db: dict[str, Sensor], + requesting_user, + chart_type, +): + site = charge_hub_fresh_db["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) + + +@pytest.mark.parametrize("requesting_user", ["test_admin_user@seita.nl"], indirect=True) +@pytest.mark.parametrize("chart_type", ["test-plugin-chart", "test_plugin_chart"]) +def test_chart_accepts_a_chart_type_registered_by_a_plugin( + client, + setup_roles_users_fresh_db, + charge_hub_fresh_db: dict[str, Sensor], + plugin_asset_chart_type, + requesting_user, + chart_type, +): + """A plugin registers its chart types after the API was set up, so the chart type field should read the registry when it validates.""" + site = charge_hub_fresh_db["arrival"].generic_asset + response = _get_chart(client, site, {"chart-type": chart_type}) + assert response.status_code == 200, response.data + # The test plugin's chart type draws the default chart. + assert json.loads(response.data) == json.loads( + _get_chart(client, site, {"chart-type": "default"}).data + ) diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index 03da927b0e..d49ae83825 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,183 @@ def import_hook(name, *args, **kwargs): setattr(module, "server_now", original_server_now) except Exception: pass + + +@pytest.fixture +def charge_hub_fresh_db(fresh_db, setup_accounts_fresh_db) -> dict[str, Sensor]: + """A site with a charge point, owned by the Prosumer organisation, 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. + The Prices graph also holds a price sensor of another organisation's (non-public) asset, + which the site's dashboard may not show, so its data should never be loaded or drawn. + + :returns: the site's sensors, by name (without the other organisation's sensor). + """ + from flexmeasures.data.models.charts.chart_types import ( + CHARGE_POINT_ASSET_TYPE_NAME, + ) + + site_type = GenericAssetType(name="test_site_type_for_chart_sensors") + charge_point_type = fresh_db.session.scalar( + select(GenericAssetType).filter_by(name=CHARGE_POINT_ASSET_TYPE_NAME) + ) or GenericAssetType(name=CHARGE_POINT_ASSET_TYPE_NAME) + fresh_db.session.add_all([site_type, charge_point_type]) + site = GenericAsset( + name="Test charge hub (chart sensors)", + generic_asset_type=site_type, + owner=setup_accounts_fresh_db["Prosumer"], + ) + 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, + owner=setup_accounts_fresh_db["Prosumer"], + ) + ) + other_site = GenericAsset( + name="Another organisation's site (chart sensors)", + generic_asset_type=site_type, + owner=setup_accounts_fresh_db["Supplier"], + ) + fresh_db.session.add(other_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() + } + other_price = Sensor( + name="other organisation's price", + unit="EUR/MWh", + event_resolution=timedelta(minutes=15), + generic_asset=other_site, + ) + fresh_db.session.add_all([*sensors.values(), other_price]) + 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, other_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(), other_price]: + 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 + + +@pytest.fixture +def plugin_asset_chart_type(app): + """An asset chart type registered after the app was created, as a plugin would register one. + + It draws the default chart, and applies to assets with charge points, so the picker offers it on a site with charge points only. + The registry is restored afterwards. + """ + from flexmeasures.data.models.charts.belief_charts import ( + chart_for_multiple_sensors, + ) + from flexmeasures.data.models.charts.chart_types import ( + ASSET_CHART_TYPES, + LEGACY_ASSET_CHART_TYPE_NAMES, + AssetChartType, + default_layout, + has_charge_points, + register_asset_chart_type, + ) + + chart_type = register_asset_chart_type( + AssetChartType( + name="test-plugin-chart", + label="Test plugin chart", + description="the default chart, registered by a test plugin", + applies_to=has_charge_points, + layout=default_layout, + builder=chart_for_multiple_sensors, + legacy_name="test_plugin_chart", + ) + ) + try: + yield chart_type + finally: + ASSET_CHART_TYPES.pop(chart_type.name, None) + LEGACY_ASSET_CHART_TYPE_NAMES.pop(chart_type.legacy_name, None) + + +@pytest.fixture +def plugin_sensor_chart_type(app): + """A sensor chart type registered after the app was created, as a plugin would register one. + + It draws a bar chart, and applies to sensors named "power" only, so the sensor page offers it on those sensors only. + The registry is restored afterwards. + """ + from flexmeasures.data.models.charts.belief_charts import bar_chart + from flexmeasures.data.models.charts.chart_types import ( + LEGACY_SENSOR_CHART_TYPE_NAMES, + SENSOR_CHART_TYPES, + SensorChartType, + register_sensor_chart_type, + ) + + chart_type = register_sensor_chart_type( + SensorChartType( + name="test-plugin-sensor-chart", + label="Test plugin sensor chart", + description="a bar chart, registered by a test plugin", + applies_to=lambda sensor: sensor.name == "power", + builder=bar_chart, + legacy_name="test_plugin_sensor_chart", + ) + ) + try: + yield chart_type + finally: + SENSOR_CHART_TYPES.pop(chart_type.name, None) + LEGACY_SENSOR_CHART_TYPE_NAMES.pop(chart_type.legacy_name, None) diff --git a/flexmeasures/data/models/charts/__init__.py b/flexmeasures/data/models/charts/__init__.py index 28dee96b1f..60ce79ed8f 100644 --- a/flexmeasures/data/models/charts/__init__.py +++ b/flexmeasures/data/models/charts/__init__.py @@ -1,50 +1,34 @@ from __future__ import annotations -from typing import Callable - -from . import belief_charts -from .defaults import apply_chart_defaults - -# The chart types of a sensor chart, by the name a request gives them. -# The generic heatmap helper is left out: it is how daily_heatmap and weekly_heatmap are built. -SENSOR_CHART_TYPES: dict[str, Callable[..., dict]] = { - "bar_chart": belief_charts.bar_chart, - "histogram": belief_charts.histogram, - "daily_heatmap": belief_charts.daily_heatmap, - "weekly_heatmap": belief_charts.weekly_heatmap, -} - -# The chart builders of an asset chart, by the name a request gives them. -ASSET_CHART_BUILDERS: dict[str, Callable[..., dict]] = { - "chart_for_multiple_sensors": belief_charts.chart_for_multiple_sensors, - "chart_for_chargepoint_sessions": belief_charts.chart_for_chargepoint_sessions, -} - -# Every chart type that chart_type_to_chart_specs builds. -CHART_TYPES: dict[str, Callable[..., dict]] = { - **SENSOR_CHART_TYPES, - **ASSET_CHART_BUILDERS, -} - - -class UnknownChartType(ValueError): - """Raised for a chart type that is not among the CHART_TYPES.""" +from . import belief_charts, defaults # noqa F401 +from .chart_types import ( + SENSOR_CHART_TYPES, + SensorChartType, + UnknownChartType, + get_sensor_chart_type, +) + +# Every chart type that chart_type_to_chart_specs builds: the registry of sensor chart types (see chart_types.py), +# which plugins can add to. +# Asset charts are built through the registry of asset chart types instead. +CHART_TYPES: dict[str, SensorChartType] = SENSOR_CHART_TYPES + +__all__ = [ + "CHART_TYPES", + "SENSOR_CHART_TYPES", + "UnknownChartType", + "chart_type_to_chart_specs", +] def chart_type_to_chart_specs(chart_type: str, **kwargs) -> dict: """Create chart specs of a given chart type, using FlexMeasures defaults for settings like width and height. - Only the chart types listed in CHART_TYPES are built, + Only the chart types in the registry of sensor chart types (CHART_TYPES) are built, so a request cannot reach other functions or data in the chart modules. - :param chart_type: Name of a chart type in CHART_TYPES. + :param chart_type: Name of a chart type in CHART_TYPES, or its legacy name (e.g. "bar_chart" for "bar-chart"). :returns: A dictionary containing a vega-lite chart specification :raises UnknownChartType: if the chart type is not in CHART_TYPES. """ - try: - chart_builder = CHART_TYPES[chart_type] - except KeyError: - raise UnknownChartType( - f"Unknown chart type '{chart_type}'; choose from {', '.join(CHART_TYPES)}." - ) from None - return apply_chart_defaults(chart_builder)(**kwargs) + return get_sensor_chart_type(chart_type).chart_specs(**kwargs) diff --git a/flexmeasures/data/models/charts/belief_charts.py b/flexmeasures/data/models/charts/belief_charts.py index ab2f9652e8..88929a4ba3 100644 --- a/flexmeasures/data/models/charts/belief_charts.py +++ b/flexmeasures/data/models/charts/belief_charts.py @@ -42,7 +42,7 @@ } -def _charge_point_session_sensors( +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.""" @@ -64,6 +64,8 @@ def charge_point_companion_graph(entry: dict) -> dict | None: so a graph titled "prices" counts as a Prices graph. Of a Power flow by type graph, only the charge points power is kept. + A graph left without sensors is not drawn. + :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. """ @@ -71,7 +73,7 @@ def charge_point_companion_graph(entry: dict) -> dict | None: if not title or capitalize(title) not in CHARGE_POINT_COMPANION_TITLES: return None if capitalize(title) != POWER_FLOW_BY_TYPE_TITLE: - return entry + return entry if _extract_sensors_from_entry(entry) else None plots = [] for plot in entry.get("plots", []): if "sensors" in plot: @@ -86,7 +88,8 @@ def charge_point_companion_graph(entry: dict) -> dict | None: elif "sensor" in plot and plot["sensor"].name != CHARGE_POINT_POWER_SENSOR_NAME: continue plots.append(plot) - return {**entry, "plots": plots} + graph = {**entry, "plots": plots} + return graph if _extract_sensors_from_entry(graph) else None def charge_point_chart_sensors( @@ -100,7 +103,7 @@ def charge_point_chart_sensors( :param sensors_to_show: a validated sensors_to_show. :returns: a unique list of sensors. """ - session_sensors = _charge_point_session_sensors(sensors_to_show) + session_sensors = charge_point_session_sensors(sensors_to_show) companion_sensors = [ sensor for entry in sensors_to_show @@ -1247,7 +1250,7 @@ def chart_for_chargepoint_sessions( **override_chart_specs, ) -> dict: - all_sensors = _charge_point_session_sensors(sensors_to_show) + all_sensors = charge_point_session_sensors(sensors_to_show) sensor_ids = [s.id for s in all_sensors] @@ -1524,11 +1527,19 @@ def chart_for_chargepoint_sessions( }, ], } - # The companion graphs hold only what is drawn (see charge_point_companion_graph); + # The companion graphs hold only what is drawn (see charge_point_companion_graph), + # so a companion graph left without sensors is not built at all; # 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 - ] + sensors_to_show_copy = [] + for entry in sensors_to_show: + graph = charge_point_companion_graph(entry) + if graph is not None: + sensors_to_show_copy.append(graph) + elif ( + not entry.get("title") + or capitalize(entry["title"]) not in CHARGE_POINT_COMPANION_TITLES + ): + sensors_to_show_copy.append(entry) chart_specs = chart_for_multiple_sensors( sensors_to_show_copy, event_starts_after, diff --git a/flexmeasures/data/models/charts/chart_types.py b/flexmeasures/data/models/charts/chart_types.py new file mode 100644 index 0000000000..dd10fc5e90 --- /dev/null +++ b/flexmeasures/data/models/charts/chart_types.py @@ -0,0 +1,388 @@ +"""The registries of chart types: the one place that lists the charts an asset or a sensor 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 or sensors it applies to, and which function builds its Vega-Lite specs. +An asset chart type also says which graphs it draws (its layout). + +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, Generic, TypeVar + +from flexmeasures.data.models.charts.belief_charts import ( + CHARGE_POINT_SESSIONS_TITLE, + bar_chart, + charge_point_session_sensors, + charge_point_companion_graph, + chart_for_chargepoint_sessions, + chart_for_multiple_sensors, + daily_heatmap, + histogram, + weekly_heatmap, +) +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 UnknownChartType(ValueError): + """Raised for a name that is not in a registry of chart types.""" + + +class UnknownAssetChartType(UnknownChartType): + """Raised for a name that is not in the registry of asset chart types.""" + + +class UnknownSensorChartType(UnknownChartType): + """Raised for a name that is not in the registry of sensor chart types.""" + + +class ChartType: + """What the asset and sensor chart types have in common: a public name, an optional legacy name, and a builder of Vega-Lite specs.""" + + name: str + legacy_name: str | None + builder: Callable[..., dict] + + 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) + + +ChartTypeT = TypeVar("ChartTypeT", bound=ChartType) + + +class ChartTypeRegistry(Generic[ChartTypeT]): + """The chart types of one kind (asset or sensor chart types), by their public names. + + It also maps the legacy names, which the chart endpoints took before the chart types had public names, to those public names. + """ + + def __init__(self, kind: str, unknown_error: type[UnknownChartType]): + """Set up an empty registry. + + :param kind: what the chart types draw, as named in messages (e.g. "asset"). + :param unknown_error: the error to raise for a name that is not in the registry. + """ + self.kind = kind + self.unknown_error = unknown_error + self.chart_types: dict[str, ChartTypeT] = {} + self.legacy_names: dict[str, str] = {} + + def register(self, chart_type: ChartTypeT) -> ChartTypeT: + """Add a chart type to the registry. + + :param chart_type: the chart type to register. + :returns: the registered chart type. + :raises ValueError: if its name or legacy name is already taken, as a name or legacy name of a registered chart type. + """ + taken = set(self.chart_types) | set(self.legacy_names) + for name in (chart_type.name, chart_type.legacy_name): + if name in taken: + raise ValueError( + f"Cannot register {self.kind} chart type '{chart_type.name}': the name '{name}' is already taken by a registered {self.kind} chart type." + ) + if chart_type.name == chart_type.legacy_name: + raise ValueError( + f"Cannot register {self.kind} chart type '{chart_type.name}': its legacy name should differ from its name." + ) + self.chart_types[chart_type.name] = chart_type + if chart_type.legacy_name: + self.legacy_names[chart_type.legacy_name] = chart_type.name + return chart_type + + def public_name(self, name: str) -> str: + """The public name for a legacy name; any other name is returned as it is.""" + return self.legacy_names.get(name, name) + + def get(self, name: str, accept_legacy_name: bool = True) -> ChartTypeT: + """Look up a 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 legacy name. + :returns: the chart type. + :raises UnknownChartType: (in fact, the registry's unknown_error) if no chart type goes by that name. + """ + if accept_legacy_name: + name = self.public_name(name) + try: + return self.chart_types[name] + except KeyError: + message = f"Unknown {self.kind} chart type '{name}'; choose from {', '.join(self.chart_types)}" + if accept_legacy_name and self.legacy_names: + message += f" (or the deprecated {', '.join(self.legacy_names)})" + raise self.unknown_error(message + ".") from None + + +@dataclass(frozen=True) +class AssetChartType(ChartType): + """A chart an asset can be drawn as. + + Plugins can add their own, using register_asset_chart_type. + These fields are provisional: they may still change before plugins can rely on them. + + :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 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 _as_graph(entry: dict) -> dict: + """The entry in the layout format, i.e. with its sensors in plots. + + Validated entries have that format already, + except those suggested for an asset without sensors_to_show, which hold a top-level sensor or sensors. + """ + if "plots" in entry: + return entry + graph = { + key: value for key, value in entry.items() if key not in ("sensor", "sensors") + } + plots = [] + if "sensor" in entry: + plots.append({"sensor": entry["sensor"]}) + if "sensors" in entry: + plots.append({"sensors": entry["sensors"]}) + return {**graph, "plots": plots} + + +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 [ + _as_graph(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). + """ + sensors_to_show = [_as_graph(entry) for entry in sensors_to_show] + 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_TYPE_REGISTRY: ChartTypeRegistry[AssetChartType] = ChartTypeRegistry( + "asset", UnknownAssetChartType +) + +# The registry: the asset chart types by their public names. +ASSET_CHART_TYPES: dict[str, AssetChartType] = ASSET_CHART_TYPE_REGISTRY.chart_types + +# 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] = ASSET_CHART_TYPE_REGISTRY.legacy_names + + +def register_asset_chart_type(chart_type: AssetChartType) -> AssetChartType: + """Add a chart type to the registry of asset chart types. + + Plugins call this when they are set up (e.g. in their ``__init__``), + after which the chart API accepts the chart type's name and the asset page offers it on the assets it applies to. + To avoid clashing with chart types FlexMeasures may add later, + prefix the name with the plugin's name (e.g. ``"my-plugin-heat-pumps"``). + + :param chart_type: the chart type to register. + :returns: the registered chart type. + :raises ValueError: if its name or legacy name is already taken, as a name or legacy name of a registered chart type. + """ + return ASSET_CHART_TYPE_REGISTRY.register(chart_type) + + +register_asset_chart_type( + AssetChartType( + name=DEFAULT_ASSET_CHART_TYPE, + label="Default", + description="a graph for each entry in the asset's sensors_to_show, except the charging sessions, which only 'charge-point-sessions' draws", + applies_to=lambda asset: True, + layout=default_layout, + builder=chart_for_multiple_sensors, + legacy_name="chart_for_multiple_sensors", + ) +) +register_asset_chart_type( + 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", + ) +) + + +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. + """ + return ASSET_CHART_TYPE_REGISTRY.get(name, accept_legacy_name=accept_legacy_name) + + +@dataclass(frozen=True) +class SensorChartType(ChartType): + """A chart a sensor can be drawn as. + + Plugins can add their own, using register_sensor_chart_type. + These fields are provisional: they may still change before plugins can rely on them. + + :param name: the public, kebab-case name, as the sensor chart endpoint takes it in ``chart-type``. + :param label: the name shown in the UI. + :param description: what the chart shows. + :param applies_to: whether the chart type is meant for this sensor. + :param builder: the function that builds the chart's Vega-Lite specs, + from the sensor, the window of events (``event_starts_after`` and ``event_ends_before``) and further chart settings. + :param legacy_name: the name the chart endpoint took before the chart types had public names. + """ + + name: str + label: str + description: str + applies_to: Callable[["Sensor"], bool] # noqa F821 + builder: Callable[..., dict] + legacy_name: str | None = None + + +DEFAULT_SENSOR_CHART_TYPE = "bar-chart" + +SENSOR_CHART_TYPE_REGISTRY: ChartTypeRegistry[SensorChartType] = ChartTypeRegistry( + "sensor", UnknownSensorChartType +) + +# The registry: the sensor chart types by their public names. +SENSOR_CHART_TYPES: dict[str, SensorChartType] = SENSOR_CHART_TYPE_REGISTRY.chart_types + +# The names the sensor chart endpoint took before the chart types had public names, mapped to those public names. +LEGACY_SENSOR_CHART_TYPE_NAMES: dict[str, str] = SENSOR_CHART_TYPE_REGISTRY.legacy_names + + +def register_sensor_chart_type(chart_type: SensorChartType) -> SensorChartType: + """Add a chart type to the registry of sensor chart types. + + Plugins call this when they are set up (e.g. in their ``__init__``), + after which the sensor chart endpoint accepts the chart type's name and the sensor page offers it on the sensors it applies to. + To avoid clashing with chart types FlexMeasures may add later, + prefix the name with the plugin's name (e.g. ``"my-plugin-duration-curve"``). + + :param chart_type: the chart type to register. + :returns: the registered chart type. + :raises ValueError: if its name or legacy name is already taken, as a name or legacy name of a registered chart type. + """ + return SENSOR_CHART_TYPE_REGISTRY.register(chart_type) + + +def get_sensor_chart_type( + name: str, accept_legacy_name: bool = True +) -> SensorChartType: + """Look up a sensor 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 the chart endpoint took before the chart types had public names. + :returns: the chart type. + :raises UnknownSensorChartType: if no chart type goes by that name. + """ + return SENSOR_CHART_TYPE_REGISTRY.get(name, accept_legacy_name=accept_legacy_name) + + +def _applies_to_any_sensor(sensor: "Sensor") -> bool: # noqa F821 + """Whether the chart type applies to the sensor, which the built-in sensor chart types all do.""" + return True + + +# The built-in sensor chart types, which the fast chart on the sensor page also draws (see fast-chart.js). +# The generic heatmap helper is left out: it is how the daily and weekly heatmaps are built. +register_sensor_chart_type( + SensorChartType( + name=DEFAULT_SENSOR_CHART_TYPE, + label="Bar chart", + description="the sensor's data over time, as bars", + applies_to=_applies_to_any_sensor, + builder=bar_chart, + legacy_name="bar_chart", + ) +) +register_sensor_chart_type( + SensorChartType( + name="histogram", + label="Histogram", + description="how often the sensor's values fall in each bin", + applies_to=_applies_to_any_sensor, + builder=histogram, + ) +) +register_sensor_chart_type( + SensorChartType( + name="daily-heatmap", + label="Daily heatmap", + description="the sensor's data as a heatmap, with a row per day", + applies_to=_applies_to_any_sensor, + builder=daily_heatmap, + legacy_name="daily_heatmap", + ) +) +register_sensor_chart_type( + SensorChartType( + name="weekly-heatmap", + label="Weekly heatmap", + description="the sensor's data as a heatmap, with a row per week", + applies_to=_applies_to_any_sensor, + builder=weekly_heatmap, + legacy_name="weekly_heatmap", + ) +) diff --git a/flexmeasures/data/models/charts/readme.md b/flexmeasures/data/models/charts/readme.md index bfcb6235d2..e5e986098f 100644 --- a/flexmeasures/data/models/charts/readme.md +++ b/flexmeasures/data/models/charts/readme.md @@ -1,10 +1,17 @@ # Developer docs for adding chart specs -A chart type is a function in `belief_charts` that returns a dict (with vega-lite specs) or an altair chart. +A chart builder is a function in `belief_charts` that returns a dict (with vega-lite specs) or an altair chart. Parameterize the specification with kwargs. -Only the chart types listed in `SENSOR_CHART_TYPES` or `ASSET_CHART_BUILDERS` (in `charts/__init__.py`) are built, -so list a new chart type there to make it available to chart requests. -`chart_type_to_chart_specs` applies the FlexMeasures defaults (see `defaults.py`) to a copy of the specs. +Only the chart types listed in the registries in `chart_types.py` are built, +so register a new chart type there to make it available to chart requests. +There is one registry for the chart types of an asset (`ASSET_CHART_TYPES`) and one for those of a sensor (`SENSOR_CHART_TYPES`). +Each chart type has a public, kebab-case name, the assets or sensors it applies to, and the function that builds its chart specs; +an asset chart type also has a layout (the graphs it draws). +Legacy names, which the chart endpoints took before the chart types had public names, map to the public names and are still accepted. +`chart_type_to_chart_specs` builds a sensor chart type, applying the FlexMeasures defaults (see `defaults.py`) to a copy of the specs. -Todo: support a plug-in architecture, see https://packaging.python.org/guides/creating-and-discovering-plugins/ +Plugins add their own chart types with `register_asset_chart_type` and `register_sensor_chart_type`, +when they are set up (see "Adding your own chart types" in `documentation/plugin/customisation.rst`). +The asset and sensor pages offer the registered chart types that apply, +and the fast chart (`fast-chart.js`) draws only the built-in ones, leaving the others to Vega-Lite. diff --git a/flexmeasures/data/models/generic_assets.py b/flexmeasures/data/models/generic_assets.py index 0f42889d84..edb440b659 100644 --- a/flexmeasures/data/models/generic_assets.py +++ b/flexmeasures/data/models/generic_assets.py @@ -17,7 +17,10 @@ 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 ( + 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, @@ -1002,14 +1005,15 @@ 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. + :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). """ 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 == "chart_for_multiple_sensors": + if chart_type == "default": # A sensor that is also in another graph is still drawn, so it stays. return SensorsToShowSchema.flatten( [ @@ -1018,13 +1022,13 @@ def chart_sensors( if entry.get("title") != CHARGE_POINT_SESSIONS_TITLE ] ) - if chart_type == "chart_for_chargepoint_sessions": + if chart_type == "charge-point-sessions": return charge_point_chart_sensors(sensors_to_show) return SensorsToShowSchema.flatten(sensors_to_show) 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, @@ -1042,7 +1046,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) @@ -1055,6 +1060,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 @@ -1071,8 +1077,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/models/time_series.py b/flexmeasures/data/models/time_series.py index 39ff2a7671..8978bd6ddd 100644 --- a/flexmeasures/data/models/time_series.py +++ b/flexmeasures/data/models/time_series.py @@ -521,7 +521,7 @@ def search_beliefs( # noqa: C901 def chart( self, - chart_type: str = "bar_chart", + chart_type: str = "bar-chart", event_starts_after: datetime_type | None = None, event_ends_before: datetime_type | None = None, beliefs_after: datetime_type | None = None, @@ -540,7 +540,7 @@ def chart( ) -> dict: """Create a vega-lite chart showing sensor data. - :param chart_type: one of the SENSOR_CHART_TYPES (see flexmeasures.data.models.charts), such as "bar_chart" + :param chart_type: name of a sensor chart type, as listed in the registry of sensor chart types (SENSOR_CHART_TYPES in flexmeasures.data.models.charts.chart_types), such as "bar-chart"; legacy names such as "bar_chart" 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) diff --git a/flexmeasures/data/schemas/charts.py b/flexmeasures/data/schemas/charts.py new file mode 100644 index 0000000000..c7f421b517 --- /dev/null +++ b/flexmeasures/data/schemas/charts.py @@ -0,0 +1,149 @@ +"""Fields for choosing which chart to draw.""" + +from __future__ import annotations + +from marshmallow import ValidationError, fields, validate + +from flexmeasures.data.models.charts.chart_types import ( + ASSET_CHART_TYPE_REGISTRY, + DEFAULT_ASSET_CHART_TYPE, + DEFAULT_SENSOR_CHART_TYPE, + SENSOR_CHART_TYPE_REGISTRY, + ChartTypeRegistry, +) + + +def describe_chart_types( + registry: ChartTypeRegistry, include_legacy_names: bool = False +) -> str: + """List the chart types from a registry, for a field description. + + :param registry: the registry of asset or sensor chart types. + :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 registry.chart_types.items() + ) + if include_legacy_names and registry.legacy_names: + legacy_names = " and ".join( + f"'{legacy_name}' (for '{name}')" + for legacy_name, name in registry.legacy_names.items() + ) + description += ( + f". The legacy values {legacy_names} are deprecated, but still accepted" + ) + return description + + +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. + """ + return describe_chart_types( + ASSET_CHART_TYPE_REGISTRY, include_legacy_names=include_legacy_names + ) + + +def describe_sensor_chart_types(include_legacy_names: bool = False) -> str: + """List the sensor chart types from the registry, for a field description. + + :param include_legacy_names: whether to also mention the legacy names that are still accepted. + """ + return describe_chart_types( + SENSOR_CHART_TYPE_REGISTRY, include_legacy_names=include_legacy_names + ) + + +class RegisteredChartType(validate.Validator): + """Validate that a name is in a registry of chart types, reading the registry each time it validates. + + Unlike validate.OneOf, which copies its choices when it is set up, + this validator also accepts the chart types that plugins register after the field was made. + """ + + error = "Must be one of: {choices}." + + def __init__(self, registry: ChartTypeRegistry): + self.registry = registry + + @property + def choices(self) -> list[str]: + """The names of the registered chart types (which the OpenAPI specs list as the field's enum).""" + return list(self.registry.chart_types) + + def _repr_args(self) -> str: + return f"choices={self.choices!r}" + + def __call__(self, value: str) -> str: + if value not in self.registry.chart_types: + raise ValidationError(self.error.format(choices=", ".join(self.choices))) + return value + + +class RegisteredAssetChartType(RegisteredChartType): + """Validate that a name is in the registry of asset chart types, reading the registry each time it validates.""" + + def __init__(self): + super().__init__(ASSET_CHART_TYPE_REGISTRY) + + +class RegisteredSensorChartType(RegisteredChartType): + """Validate that a name is in the registry of sensor chart types, reading the registry each time it validates.""" + + def __init__(self): + super().__init__(SENSOR_CHART_TYPE_REGISTRY) + + +class ChartTypeField(fields.Str): + """The name of a chart type, validated against a registry of chart types. + + The registry is read when a name is validated, so the field also accepts chart types that plugins register. + An unknown name fails validation, which the API answers with a 422. + Subclasses say which registry, and which chart type serves as the example. + """ + + registry: ChartTypeRegistry + default_chart_type: str + + def __init__(self, accept_legacy_names: bool = False, **kwargs): + """Set up the field. + + The name is checked against the registry first, and then by any validators passed as validate. + The legacy names, if accepted, are mapped before that check, so they are not among the allowed values (the enum, in OpenAPI). + + :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 + extra_validators = kwargs.pop("validate", None) or [] + if callable(extra_validators): + extra_validators = [extra_validators] + kwargs["validate"] = [ + RegisteredChartType(self.registry), + *extra_validators, + ] + metadata = kwargs.setdefault("metadata", {}) + metadata.setdefault("example", self.default_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 self.registry.public_name(name) + return name + + +class AssetChartTypeField(ChartTypeField): + """The name of an asset chart type, validated against the registry of asset chart types.""" + + registry = ASSET_CHART_TYPE_REGISTRY + default_chart_type = DEFAULT_ASSET_CHART_TYPE + + +class SensorChartTypeField(ChartTypeField): + """The name of a sensor chart type, validated against the registry of sensor chart types.""" + + registry = SENSOR_CHART_TYPE_REGISTRY + default_chart_type = DEFAULT_SENSOR_CHART_TYPE 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..f0a165af4c --- /dev/null +++ b/flexmeasures/data/tests/test_asset_chart_types_fresh_db.py @@ -0,0 +1,260 @@ +"""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, +) +from flexmeasures.data.models.charts.chart_types import ( + ASSET_CHART_TYPES, + UnknownAssetChartType, + get_asset_chart_type, +) +from flexmeasures.data.schemas.generic_assets import SensorsToShowSchema +from flexmeasures.utils.flexmeasures_inflection import capitalize + + +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_fresh_db: 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_fresh_db["arrival"].generic_asset + _retitle( + fresh_db, site, {"Prices": prices_title, "Power flow by type": power_flow_title} + ) + + chart_specs = site.chart(chart_type="charge-point-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_fresh_db[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_fresh_db: dict[str, Sensor], +): + site = charge_hub_fresh_db["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_fresh_db.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 + + +@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_fresh_db: 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_fresh_db["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_fresh_db: dict[str, Sensor], chart_type, expected_names +): + site = charge_hub_fresh_db["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_fresh_db: dict[str, Sensor], legacy_name, name +): + site = charge_hub_fresh_db["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_fresh_db: dict[str, Sensor], +): + site = charge_hub_fresh_db["arrival"].generic_asset + assert site.chart() == site.chart(chart_type="default") + + +def test_an_unknown_chart_type_is_refused(charge_hub_fresh_db: dict[str, Sensor]): + site = charge_hub_fresh_db["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_fresh_db: dict[str, Sensor], +): + site = charge_hub_fresh_db["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"] + + +def test_a_companion_graph_left_without_sensors_is_not_in_the_layout( + fresh_db, charge_hub_fresh_db: dict[str, Sensor] +): + """Of a Power flow by type graph without the charge points power, nothing is drawn, so the layout leaves it out.""" + site = charge_hub_fresh_db["arrival"].generic_asset + site.sensors_to_show = [ + ( + {**entry, "sensors": [charge_hub_fresh_db["building power"].id]} + if entry["title"] == "Power flow by type" + else entry + ) + for entry in site.sensors_to_show + ] + fresh_db.session.commit() + layout = get_asset_chart_type("charge-point-sessions").layout( + site.validate_sensors_to_show() + ) + + chart_specs = site.chart(chart_type="charge-point-sessions") + + assert [graph["title"] for graph in layout] == [ + graph["title"] for graph in chart_specs["vconcat"] + ] + assert [graph["title"] for graph in layout] == ["Charge Point sessions", "Prices"] + + +@pytest.mark.parametrize("chart_type", ["default", "charge-point-sessions"]) +def test_a_layout_holds_its_sensors_in_plots_also_for_suggested_graphs( + fresh_db, charge_hub_fresh_db: dict[str, Sensor], chart_type +): + """Without sensors_to_show, an asset gets suggested graphs, which hold their sensors outside plots.""" + site = charge_hub_fresh_db["arrival"].generic_asset + site.sensors_to_show = [] + fresh_db.session.commit() + suggested = site.validate_sensors_to_show() + assert suggested and any("plots" not in entry for entry in suggested) + + layout = get_asset_chart_type(chart_type).layout(suggested) + + assert all(set(graph) >= {"title", "plots"} for graph in layout) + if chart_type == "default": + assert get_asset_chart_type(chart_type).sensors( + suggested + ) == SensorsToShowSchema.flatten(suggested) diff --git a/flexmeasures/data/tests/test_chart_types.py b/flexmeasures/data/tests/test_chart_types.py new file mode 100644 index 0000000000..f1c9dd9f07 --- /dev/null +++ b/flexmeasures/data/tests/test_chart_types.py @@ -0,0 +1,213 @@ +"""Tests for the registries of asset and sensor chart types (issue #2795).""" + +import pytest +from marshmallow import ValidationError, validate + +from flexmeasures.data.models.charts import UnknownChartType +from flexmeasures.data.models.charts.chart_types import ( + ASSET_CHART_TYPES, + DEFAULT_ASSET_CHART_TYPE, + DEFAULT_SENSOR_CHART_TYPE, + LEGACY_ASSET_CHART_TYPE_NAMES, + LEGACY_SENSOR_CHART_TYPE_NAMES, + SENSOR_CHART_TYPES, + AssetChartType, + SensorChartType, + UnknownAssetChartType, + UnknownSensorChartType, + get_asset_chart_type, + get_sensor_chart_type, + register_asset_chart_type, + register_sensor_chart_type, +) +from flexmeasures.data.schemas.charts import AssetChartTypeField, SensorChartTypeField + +# Fields made at import, like those of the API schemas, before plugins register their chart types. +FIELD_MADE_BEFORE_PLUGINS_REGISTER = AssetChartTypeField(accept_legacy_names=True) +SENSOR_FIELD_MADE_BEFORE_PLUGINS_REGISTER = SensorChartTypeField( + accept_legacy_names=True +) + + +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 \\(or the deprecated", + ): + get_asset_chart_type("daily_heatmap") + with pytest.raises( + UnknownAssetChartType, match="default, charge-point-sessions\\.$" + ): + get_asset_chart_type("daily_heatmap", accept_legacy_name=False) + + +def test_a_chart_type_field_checks_the_registry_before_its_own_validators(): + field = AssetChartTypeField( + accept_legacy_names=True, + validate=validate.NoneOf(["charge-point-sessions"]), + ) + assert field.deserialize("default") == "default" + assert field.deserialize("chart_for_multiple_sensors") == "default" + with pytest.raises(ValidationError, match="Must be one of"): + field.deserialize("bar_chart") + # The extra validator sees the mapped name. + with pytest.raises(ValidationError, match="Invalid input"): + field.deserialize("chart_for_chargepoint_sessions") + + +def test_a_chart_type_registered_by_a_plugin_is_looked_up_and_accepted_by_the_field( + plugin_asset_chart_type, +): + field = FIELD_MADE_BEFORE_PLUGINS_REGISTER + assert get_asset_chart_type("test-plugin-chart") is plugin_asset_chart_type + assert field.deserialize("test-plugin-chart") == "test-plugin-chart" + assert field.deserialize("test_plugin_chart") == "test-plugin-chart" + with pytest.raises( + ValidationError, + match="Must be one of: default, charge-point-sessions, test-plugin-chart\\.", + ): + field.deserialize("bar_chart") + + +@pytest.mark.parametrize( + "name, legacy_name", + [ + ("charge-point-sessions", None), + ("chart_for_chargepoint_sessions", None), + ("test-plugin-chart-2", "default"), + ("test-plugin-chart-2", "chart_for_multiple_sensors"), + ("test-plugin-chart-2", "test-plugin-chart-2"), + ], +) +def test_registering_a_chart_type_refuses_a_name_already_taken(name, legacy_name): + chart_type = ASSET_CHART_TYPES["default"] + registry = dict(ASSET_CHART_TYPES) + legacy_names = dict(LEGACY_ASSET_CHART_TYPE_NAMES) + with pytest.raises(ValueError, match="Cannot register asset chart type"): + register_asset_chart_type( + AssetChartType( + name=name, + label="Taken", + description="a chart type whose name is taken", + applies_to=chart_type.applies_to, + layout=chart_type.layout, + builder=chart_type.builder, + legacy_name=legacy_name, + ) + ) + # Nothing was registered. + assert ASSET_CHART_TYPES == registry + assert LEGACY_ASSET_CHART_TYPE_NAMES == legacy_names + + +def test_the_registry_lists_the_sensor_chart_types(): + assert list(SENSOR_CHART_TYPES) == [ + "bar-chart", + "histogram", + "daily-heatmap", + "weekly-heatmap", + ] + assert DEFAULT_SENSOR_CHART_TYPE == "bar-chart" + for name, chart_type in SENSOR_CHART_TYPES.items(): + assert chart_type.name == name + + +def test_the_legacy_sensor_chart_type_names_are_the_names_of_the_chart_builders(): + """The sensor chart endpoint took the names of the chart builders, so those stay accepted (e.g. from sessions and bookmarked URLs).""" + assert LEGACY_SENSOR_CHART_TYPE_NAMES == { + "bar_chart": "bar-chart", + "daily_heatmap": "daily-heatmap", + "weekly_heatmap": "weekly-heatmap", + } + for name, chart_type in SENSOR_CHART_TYPES.items(): + assert chart_type.builder.__name__ == (chart_type.legacy_name or name) + + +def test_looking_up_a_sensor_chart_type(): + assert get_sensor_chart_type("daily-heatmap").name == "daily-heatmap" + assert get_sensor_chart_type("daily_heatmap").name == "daily-heatmap" + with pytest.raises(UnknownSensorChartType): + get_sensor_chart_type("daily_heatmap", accept_legacy_name=False) + with pytest.raises(UnknownChartType, match="Unknown sensor chart type 'heatmap'"): + get_sensor_chart_type("heatmap") + # The registries are separate: an asset chart type is not a sensor chart type. + with pytest.raises(UnknownSensorChartType): + get_sensor_chart_type("default") + + +def test_a_sensor_chart_type_registered_by_a_plugin_is_looked_up_and_accepted_by_the_field( + plugin_sensor_chart_type, +): + field = SENSOR_FIELD_MADE_BEFORE_PLUGINS_REGISTER + assert get_sensor_chart_type("test-plugin-sensor-chart") is plugin_sensor_chart_type + assert get_sensor_chart_type("test_plugin_sensor_chart") is plugin_sensor_chart_type + assert field.deserialize("test-plugin-sensor-chart") == "test-plugin-sensor-chart" + assert field.deserialize("test_plugin_sensor_chart") == "test-plugin-sensor-chart" + assert field.deserialize("bar_chart") == "bar-chart" + with pytest.raises( + ValidationError, + match="Must be one of: bar-chart, histogram, daily-heatmap, weekly-heatmap, test-plugin-sensor-chart\\.", + ): + field.deserialize("default") + + +def test_a_sensor_chart_type_field_refuses_legacy_names_unless_accepted(): + field = SensorChartTypeField() + assert field.deserialize("bar-chart") == "bar-chart" + with pytest.raises(ValidationError, match="Must be one of"): + field.deserialize("bar_chart") + + +@pytest.mark.parametrize( + "name, legacy_name", + [ + ("histogram", None), + ("bar_chart", None), + ("test-plugin-sensor-chart-2", "bar-chart"), + ("test-plugin-sensor-chart-2", "daily_heatmap"), + ("test-plugin-sensor-chart-2", "test-plugin-sensor-chart-2"), + ], +) +def test_registering_a_sensor_chart_type_refuses_a_name_already_taken( + name, legacy_name +): + chart_type = SENSOR_CHART_TYPES["bar-chart"] + registry = dict(SENSOR_CHART_TYPES) + legacy_names = dict(LEGACY_SENSOR_CHART_TYPE_NAMES) + with pytest.raises(ValueError, match="Cannot register sensor chart type"): + register_sensor_chart_type( + SensorChartType( + name=name, + label="Taken", + description="a chart type whose name is taken", + applies_to=chart_type.applies_to, + builder=chart_type.builder, + legacy_name=legacy_name, + ) + ) + # Nothing was registered. + assert SENSOR_CHART_TYPES == registry + assert LEGACY_SENSOR_CHART_TYPE_NAMES == legacy_names diff --git a/flexmeasures/ui/static/js/chart-data-utils.js b/flexmeasures/ui/static/js/chart-data-utils.js index 8ae7530e13..b1bfe50ddc 100644 --- a/flexmeasures/ui/static/js/chart-data-utils.js +++ b/flexmeasures/ui/static/js/chart-data-utils.js @@ -91,7 +91,7 @@ export function checkDSTTransitions(startDate, endDate) { export function checkSourceMasking(data, chartType) { var uniqueSourceIds = getUniqueValues(data, "source.id"); - if (chartType == "daily_heatmap" && uniqueSourceIds.length > 1) { + if (["daily-heatmap", "daily_heatmap"].includes(chartType) && uniqueSourceIds.length > 1) { showToast( "Please note that only data from the most prevalent source is shown." ); diff --git a/flexmeasures/ui/static/js/fast-chart.js b/flexmeasures/ui/static/js/fast-chart.js index ac67517625..94da0c9b84 100644 --- a/flexmeasures/ui/static/js/fast-chart.js +++ b/flexmeasures/ui/static/js/fast-chart.js @@ -1377,7 +1377,7 @@ function buildLineBarOption(elementId, groups, opts) { const allSourceTypes = new Set( groups.flatMap((g) => g.series.map((s) => lineTypeForSource(s.source))) ); - const showSourceKey = opts.chartType !== "bar_chart" && groups[0].nameBySensor && allSourceTypes.size > 1; + const showSourceKey = opts.chartType !== "bar-chart" && groups[0].nameBySensor && allSourceTypes.size > 1; const topOffset = TOP_OFFSET; // Vertical layout: subplots, then (in legends-below mode) the slider and @@ -1602,7 +1602,7 @@ function buildLineBarOption(elementId, groups, opts) { seriesMeta.push(null); // keep series/meta aligned for the tooltip formatter } group.series.forEach((s) => { - const isBar = opts.chartType === "bar_chart"; + const isBar = opts.chartType === "bar-chart"; const entry = { name: s.name, type: isBar ? "bar" : "line", @@ -1904,7 +1904,7 @@ function buildHistogramOption(elementId, data, opts) { /* ============================== heatmaps ============================== */ function buildHeatmapOption(elementId, data, opts) { - const split = opts.chartType === "weekly_heatmap" ? "weekly" : "daily"; + const split = opts.chartType === "weekly-heatmap" ? "weekly" : "daily"; const rows = mostPrevalentSourceRows(numericRows(data)); if (rows.length === 0) { return null; @@ -2412,15 +2412,18 @@ export function buildChargePointSessionsOption(elementId, data, opts) { * @param {Object[]} data - Decompressed chart data rows. * @param {Object} [options] - { groupSpec, chartType, legendsBelow, datasetName, * fullBeliefInfo }. - * chartType: "line" (default), "bar_chart", "histogram", "daily_heatmap", - * "weekly_heatmap" or "chart_for_chargepoint_sessions". + * chartType: "line" (default), "bar-chart", "histogram", "daily-heatmap", + * "weekly-heatmap" or "charge-point-sessions" (see fastChartDrawsChartType); + * the legacy names "bar_chart", "daily_heatmap" and "weekly_heatmap" are accepted, too. */ export function renderFastChart(elementId, data, options) { const container = document.getElementById(elementId); if (!container || typeof echarts === "undefined") { return; } - const opts = options || {}; + // Work with the public chart type name, also when given a legacy one. + const opts = { ...(options || {}) }; + opts.chartType = publicChartTypeName(opts.chartType); let instance = instances[elementId]; if (!instance || instance.chart.isDisposed()) { @@ -2463,9 +2466,9 @@ export function renderFastChart(elementId, data, options) { let option; if (opts.chartType === "histogram") { option = buildHistogramOption(elementId, data, opts); - } else if (opts.chartType === "daily_heatmap" || opts.chartType === "weekly_heatmap") { + } 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 sessionsTitle = (opts.chargePointChart || {}).sessionsTitle; @@ -2691,7 +2694,42 @@ export function refreshFastChartAnnotations(elementId, annotations) { // Charts with a time x-axis carry the toolbox dataZoom (zoom/reset) feature and a // shared, resettable zoom; the category-axis charts (histogram, heatmaps) do not. function isZoomableChartType(chartType) { - return !["histogram", "daily_heatmap", "weekly_heatmap"].includes(chartType); + return !["histogram", "daily-heatmap", "weekly-heatmap"].includes(publicChartTypeName(chartType)); +} + +// The legacy names of the sensor chart types, mapped to their public, kebab-case names (see flexmeasures/data/models/charts/chart_types.py). +const LEGACY_CHART_TYPE_NAMES = new Map([ + ["bar_chart", "bar-chart"], + ["daily_heatmap", "daily-heatmap"], + ["weekly_heatmap", "weekly-heatmap"], +]); + +// The chart types the fast chart draws, by their public names: +// the default asset chart (drawn as lines), the Charge Points chart, and the built-in sensor chart types. +// Chart types registered by plugins are not among them, so pages draw those with Vega-Lite. +const FAST_CHART_TYPES = [ + "line", + "default", + "charge-point-sessions", + "bar-chart", + "histogram", + "daily-heatmap", + "weekly-heatmap", +]; + +/** + * The public name of a chart type, given its public or legacy name; any other name is returned as it is. + */ +export function publicChartTypeName(chartType) { + return LEGACY_CHART_TYPE_NAMES.get(chartType) || chartType; +} + +/** + * Whether the fast chart draws this chart type (given by its public or legacy name). + * For other chart types, such as those registered by plugins, pages show the Vega-Lite chart instead. + */ +export function fastChartDrawsChartType(chartType) { + return FAST_CHART_TYPES.includes(publicChartTypeName(chartType)); } // Zoom conveniences on the time-axis charts. Desktop: pre-select the toolbox "Zoom" @@ -2872,7 +2910,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/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6be8b491e3..0d987ac0a1 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": "The chart to draw; leave it out for the asset's default chart.", + "description": "Which chart to draw (default: 'default'): '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. Plugins may register more chart types.", "schema": { - "type": "string" + "type": "string", + "default": "default", + "enum": [ + "default", + "charge-point-sessions" + ], + "example": "default" }, "required": false } diff --git a/flexmeasures/ui/templates/assets/asset_graph.html b/flexmeasures/ui/templates/assets/asset_graph.html index b829c87d62..9268dfbb50 100644 --- a/flexmeasures/ui/templates/assets/asset_graph.html +++ b/flexmeasures/ui/templates/assets/asset_graph.html @@ -43,17 +43,19 @@
- + {# The chart types listed in the registry of sensor chart types (flexmeasures/data/models/charts/chart_types.py) that apply to this sensor. #} diff --git a/flexmeasures/ui/tests/js/test_chart_data_utils.py b/flexmeasures/ui/tests/js/test_chart_data_utils.py index 8d6433af66..8106d9feaf 100644 --- a/flexmeasures/ui/tests/js/test_chart_data_utils.py +++ b/flexmeasures/ui/tests/js/test_chart_data_utils.py @@ -79,6 +79,10 @@ def test_source_masking_is_pointed_out_on_heatmaps_only(assert_js): eq("a single source hides nothing", toasts.length, 0); checkSourceMasking(twoSources, "daily_heatmap"); eq("several sources on a heatmap are pointed out", toasts.length, 1); + checkSourceMasking(twoSources, "bar-chart"); + eq("also by its public name, the bar chart shows every source", toasts.length, 1); + checkSourceMasking(twoSources, "daily-heatmap"); + eq("also by its public name, the daily heatmap points them out", toasts.length, 2); """) diff --git a/flexmeasures/ui/tests/js/test_fast_chart.py b/flexmeasures/ui/tests/js/test_fast_chart.py index a8da9896b0..dcf9864cf4 100644 --- a/flexmeasures/ui/tests/js/test_fast_chart.py +++ b/flexmeasures/ui/tests/js/test_fast_chart.py @@ -562,6 +562,120 @@ def test_replay_ruler_survives_empty_data(assert_js): """) +def test_the_charge_point_sessions_chart_type_draws_the_sessions(assert_js): + """The asset page asks for the Charge Points chart by its public name (issue #2795).""" + assert_js(""" + import { renderFastChart, disposeFastChart } from "/js/fast-chart.js"; + const container = document.createElement("div"); + container.id = "charge-point-sessions-chart"; + container.style.width = "800px"; + document.body.appendChild(container); + let option; + const handlers = []; + 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(name) { handlers.push(name); }, off() {}, dispatchAction() {}, + })}; + const eventStart = Date.parse("2026-09-21T10:00:00Z"); + const sensor = (name) => ({ + id: name === "arrival" ? 1 : 2, + name, + unit: "s", + asset_id: 4, + asset_description: "Charger 4", + }); + const rows = [ + {sensor: sensor("arrival"), event_start: eventStart, event_value: eventStart}, + {sensor: sensor("departure"), event_start: eventStart, event_value: eventStart + 3600000}, + ]; + renderFastChart(container.id, rows, { + chartType: "charge-point-sessions", groupSpec: [], datasetName: "asset_4", isSensorPage: false, + chargePointChart: CHARGE_POINT_CHART_NAMES, + }); + const titles = [].concat(option.title || []).map((title) => title.text); + check("the sessions are drawn", titles.includes("Charge Point sessions"), JSON.stringify(titles)); + check("hovering a session shows its tooltip", handlers.includes("mouseover")); + disposeFastChart(container.id); + container.remove(); + """.replace("CHARGE_POINT_CHART_NAMES", json.dumps(CHARGE_POINT_CHART_NAMES))) + + +def test_the_fast_chart_draws_the_built_in_chart_types_only(assert_js): + """The fast chart knows the built-in chart types, by their public and legacy names, and not those registered by plugins (issue #2795).""" + assert_js(""" + import { fastChartDrawsChartType, publicChartTypeName } from "/js/fast-chart.js"; + for (const chartType of [ + "line", "default", "charge-point-sessions", + "bar-chart", "histogram", "daily-heatmap", "weekly-heatmap", + "bar_chart", "daily_heatmap", "weekly_heatmap", + ]) { + check(`the fast chart draws ${chartType}`, fastChartDrawsChartType(chartType)); + } + for (const chartType of ["test-plugin-sensor-chart", "heatmap", "constructor", "chart_for_multiple_sensors", ""]) { + check(`the fast chart leaves ${chartType} to Vega-Lite`, !fastChartDrawsChartType(chartType)); + } + eq("a legacy name maps to its public name", publicChartTypeName("daily_heatmap"), "daily-heatmap"); + eq("a public name stays as it is", publicChartTypeName("histogram"), "histogram"); + eq("an unknown name stays as it is", publicChartTypeName("constructor"), "constructor"); + """) + + +def test_the_sensor_chart_types_are_drawn_by_their_public_and_legacy_names(assert_js): + """The sensor page asks for the chart types by their public names; the legacy names still draw the same charts.""" + assert_js(""" + import { renderFastChart, disposeFastChart } from "/js/fast-chart.js"; + const container = document.createElement("div"); + container.id = "sensor-chart-types"; + 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 start = Date.parse("2026-01-05T00:00:00Z"); + const rows = [...Array(48).keys()].map((hour) => ({ + event_start: start + hour * 3600000, event_value: hour % 7, belief_horizon: 0, + sensor: {id: 1, name: "Power", unit: "kW", event_resolution: 3600000}, + source: {id: 1, name: "Meter", type: "other"}, + })); + const draw = (chartType) => { + option = undefined; + renderFastChart(container.id, rows, { + chartType, isSensorPage: true, + groupSpec: [{title: "Power", sensorIds: [1]}], + timeWindow: {start, end: start + 2 * 86400000}, + }); + return JSON.stringify(option); + }; + const seriesTypes = () => [...new Set([].concat(option.series || []).map((s) => s.type))]; + draw("bar-chart"); + check("bar-chart draws bars", seriesTypes().includes("bar"), JSON.stringify(seriesTypes())); + draw("daily-heatmap"); + check("daily-heatmap draws a heatmap", seriesTypes().includes("heatmap"), JSON.stringify(seriesTypes())); + draw("weekly-heatmap"); + check("weekly-heatmap draws a heatmap", seriesTypes().includes("heatmap"), JSON.stringify(seriesTypes())); + check("the daily and weekly heatmaps differ", draw("daily-heatmap") !== draw("weekly-heatmap")); + draw("histogram"); + check("histogram draws binned bars", seriesTypes().includes("bar") && !seriesTypes().includes("line"), JSON.stringify(seriesTypes())); + for (const [legacy, publicName] of [["bar_chart", "bar-chart"], ["daily_heatmap", "daily-heatmap"], ["weekly_heatmap", "weekly-heatmap"]]) { + eq(`${legacy} draws the same chart as ${publicName}`, draw(legacy), draw(publicName)); + } + disposeFastChart(container.id); + container.remove(); + """) + + def test_fixed_value_sensor_does_not_force_linear_interpolation(assert_js): """Fixed-value sensors (negative sensor id, event_resolution=0) must not force a row to linear interpolation (#2253).""" assert_js(""" diff --git a/flexmeasures/ui/tests/test_asset_graph_chart_types_fresh_db.py b/flexmeasures/ui/tests/test_asset_graph_chart_types_fresh_db.py new file mode 100644 index 0000000000..0ef35e9dd4 --- /dev/null +++ b/flexmeasures/ui/tests/test_asset_graph_chart_types_fresh_db.py @@ -0,0 +1,80 @@ +"""Tests for the chart picker on an asset's graphs page, which lists the chart types from the registry of asset chart types (issue #2795).""" + +from __future__ import annotations + +import re + +import pytest +from flask import url_for + +from flexmeasures import Sensor +from flexmeasures.data.models.charts.chart_types import LEGACY_ASSET_CHART_TYPE_NAMES +from flexmeasures.ui.tests.utils import login, logout + + +def _picker(client, asset) -> tuple[str, list[tuple[str, str]], str]: + """The picker's display style, its (chart type, label) options, and the whole page.""" + page = client.get(url_for("AssetCrudUI:graphs", id=asset.id), follow_redirects=True) + assert page.status_code == 200 + html = page.get_data(as_text=True) + (style,) = re.findall( + r'
]*style="display: (\w+)"', html + ) + options = re.findall(r'data-chart-type="([^"]+)">\s*([^<]+)', html) + return style, options, html + + +@pytest.fixture +def as_prosumer_user_fresh_db(client, setup_roles_users_fresh_db): + login(client, "test_prosumer_user@seita.nl", "testtest") + yield + logout(client) + + +def test_the_picker_offers_the_charge_points_chart_on_a_site_with_charge_points( + client, charge_hub_fresh_db: dict[str, Sensor], as_prosumer_user_fresh_db +): + site = charge_hub_fresh_db["arrival"].generic_asset + + style, options, html = _picker(client, site) + + assert style == "block" + assert options == [ + ("default", "Default"), + ("charge-point-sessions", "Charge Points"), + ] + # The page asks for the charts by their public names only. + for legacy_name in LEGACY_ASSET_CHART_TYPE_NAMES: + assert legacy_name not in html + + +def test_the_picker_stays_hidden_on_an_asset_without_charge_points( + client, charge_hub_fresh_db: dict[str, Sensor], as_prosumer_user_fresh_db +): + (charge_point,) = charge_hub_fresh_db["arrival"].generic_asset.child_assets + + style, options, _ = _picker(client, charge_point) + + assert style == "none" + assert options == [("default", "Default")] + + +def test_the_picker_offers_a_chart_type_registered_by_a_plugin_where_it_applies( + client, + charge_hub_fresh_db: dict[str, Sensor], + plugin_asset_chart_type, + as_prosumer_user_fresh_db, +): + site = charge_hub_fresh_db["arrival"].generic_asset + (charge_point,) = site.child_assets + + _, site_options, _ = _picker(client, site) + _, charge_point_options, _ = _picker(client, charge_point) + + assert site_options == [ + ("default", "Default"), + ("charge-point-sessions", "Charge Points"), + ("test-plugin-chart", "Test plugin chart"), + ] + # The test plugin's chart type applies to assets with charge points only. + assert charge_point_options == [("default", "Default")] diff --git a/flexmeasures/ui/tests/test_sensor_page_chart_types.py b/flexmeasures/ui/tests/test_sensor_page_chart_types.py new file mode 100644 index 0000000000..7a5b475a77 --- /dev/null +++ b/flexmeasures/ui/tests/test_sensor_page_chart_types.py @@ -0,0 +1,89 @@ +"""Tests for the chart picker on a sensor's page, which lists the chart types from the registry of sensor chart types (issue #2795).""" + +from __future__ import annotations + +import re + +from flask import url_for + +from flexmeasures.data.models.charts.chart_types import LEGACY_SENSOR_CHART_TYPE_NAMES +from flexmeasures.data.services.users import find_user_by_email + +BUILT_IN_OPTIONS = [ + ("bar-chart", "Bar chart"), + ("histogram", "Histogram"), + ("daily-heatmap", "Daily heatmap"), + ("weekly-heatmap", "Weekly heatmap"), +] + + +def _prosumer_sensors(db) -> dict: + """The sensors of the first Prosumer asset, by name.""" + user = find_user_by_email("test_prosumer_user@seita.nl") + sensors = {sensor.name: sensor for sensor in user.account.generic_assets[0].sensors} + db.session.expunge(user) + return sensors + + +def _sensor_page(client, sensor) -> tuple[list[tuple[str, str]], str, str]: + """The picker's (chart type, label) options, the chart type the page starts with, and the whole page.""" + page = client.get(url_for("SensorUI:get", id=sensor.id), follow_redirects=True) + assert page.status_code == 200 + html = page.get_data(as_text=True) + (picker,) = re.findall(r'
', html, re.DOTALL) + options = re.findall(r'data-chart-type="([^"]+)">([^<]+)', picker) + (chart_type,) = re.findall(r"let chartType = '([^']*)';", html) + return options, chart_type, html + + +def test_the_picker_offers_the_built_in_chart_types_by_their_public_names( + db, client, setup_assets, as_prosumer_user1 +): + sensor = _prosumer_sensors(db)["power"] + + options, chart_type, html = _sensor_page(client, sensor) + + assert options == BUILT_IN_OPTIONS + assert chart_type == "bar-chart" + # The page asks for the charts by their public names only. + for legacy_name in LEGACY_SENSOR_CHART_TYPE_NAMES: + assert f'"{legacy_name}"' not in html + assert f"'{legacy_name}'" not in html + + +def test_the_picker_offers_a_chart_type_registered_by_a_plugin_where_it_applies( + db, client, setup_assets, as_prosumer_user1, plugin_sensor_chart_type +): + sensors = _prosumer_sensors(db) + + power_options, _, html = _sensor_page(client, sensors["power"]) + capacity_options, _, _ = _sensor_page(client, sensors["consumption-capacity"]) + + assert power_options == [ + *BUILT_IN_OPTIONS, + ("test-plugin-sensor-chart", "Test plugin sensor chart"), + ] + # The test plugin's chart type applies to sensors named "power" only. + assert capacity_options == BUILT_IN_OPTIONS + # The page asks the fast chart whether it draws the chart type, and shows the Vega-Lite chart for a chart type it does not draw (see test_fast_chart.py). + assert "return fastChartDrawsChartType(chartType);" in html + + +def test_the_page_starts_with_the_chart_type_last_chosen( + db, client, setup_assets, as_prosumer_user1, plugin_sensor_chart_type +): + """The chart type in the session is used where it applies, by its public name also when the session holds a legacy name.""" + sensors = _prosumer_sensors(db) + expectations = [ + # session value, sensor, chart type the page starts with + ("daily_heatmap", "power", "daily-heatmap"), + ("weekly-heatmap", "power", "weekly-heatmap"), + ("test-plugin-sensor-chart", "power", "test-plugin-sensor-chart"), + ("test-plugin-sensor-chart", "consumption-capacity", "bar-chart"), + ("default", "power", "bar-chart"), + ] + for session_value, sensor_name, expected in expectations: + with client.session_transaction() as session: + session["chart_type"] = session_value + _, chart_type, _ = _sensor_page(client, sensors[sensor_name]) + assert chart_type == expected, (session_value, sensor_name) diff --git a/flexmeasures/ui/utils/view_utils.py b/flexmeasures/ui/utils/view_utils.py index 7ad1acf938..0289c7fa31 100644 --- a/flexmeasures/ui/utils/view_utils.py +++ b/flexmeasures/ui/utils/view_utils.py @@ -19,6 +19,7 @@ from flexmeasures.ui import flexmeasures_ui from flexmeasures.data.models.user import User, Account from flexmeasures.data.models.time_series import Sensor +from flexmeasures.data.models.charts.chart_types import DEFAULT_SENSOR_CHART_TYPE from flexmeasures.ui.utils.chart_defaults import chart_options from flexmeasures.ui.utils.color_defaults import get_color_settings @@ -68,7 +69,10 @@ def render_flexmeasures_template(html_filename: str, **variables): value = value.replace(" ", "+") variables[key] = value - variables["chart_type"] = session.get("chart_type", "bar_chart") + # The sensor page passes the chart type to start with (see initial_chart_type), checked against the chart types it offers. + variables.setdefault( + "chart_type", session.get("chart_type", DEFAULT_SENSOR_CHART_TYPE) + ) variables["page"] = html_filename.split("/")[-1].replace(".html", "") diff --git a/flexmeasures/ui/views/assets/views.py b/flexmeasures/ui/views/assets/views.py index ec61452bf4..45898597c6 100644 --- a/flexmeasures/ui/views/assets/views.py +++ b/flexmeasures/ui/views/assets/views.py @@ -51,6 +51,7 @@ get_asset_sensors_metadata, ) from flexmeasures.ui.utils.view_utils import available_units +from flexmeasures.data.models.charts.chart_types import ASSET_CHART_TYPES """ Asset crud view. @@ -401,6 +402,11 @@ def graphs(self, id: str, start_time=None, end_time=None): has_kpis=has_kpis, asset_kpis=asset_kpis, available_units=available_units(), + chart_types=[ + chart_type + for chart_type in ASSET_CHART_TYPES.values() + if chart_type.applies_to(asset) + ], current_page="Graphs", ) diff --git a/flexmeasures/ui/views/sensors.py b/flexmeasures/ui/views/sensors.py index d6f7606c1e..51da253037 100644 --- a/flexmeasures/ui/views/sensors.py +++ b/flexmeasures/ui/views/sensors.py @@ -1,6 +1,6 @@ from datetime import timedelta -from flask import current_app, request +from flask import current_app, request, session from flask_classful import FlaskView, route from flask_security import login_required from markupsafe import Markup @@ -8,6 +8,11 @@ from webargs.flaskparser import use_kwargs from flexmeasures.data import db +from flexmeasures.data.models.charts.chart_types import ( + DEFAULT_SENSOR_CHART_TYPE, + SENSOR_CHART_TYPE_REGISTRY, + SENSOR_CHART_TYPES, +) from flexmeasures.data.schemas import StartEndTimeSchema from flexmeasures.data.services.automations import ( describe_cronstr, @@ -101,6 +106,22 @@ def describe_fields_left_without_sensor(sensor: Sensor) -> tuple[str, Markup]: return confirm_note, toast +def initial_chart_type(chart_types: list) -> str: + """The chart type the sensor page starts with: the one last chosen (as stored in the session), if it is among the given chart types. + + A legacy name in the session (stored before the chart types had public names) is read as the public name it stands for. + Otherwise, the page starts with the default sensor chart type. + + :param chart_types: the sensor chart types that apply to the sensor. + """ + chart_type = SENSOR_CHART_TYPE_REGISTRY.public_name( + session.get("chart_type", DEFAULT_SENSOR_CHART_TYPE) + ) + if chart_type in [chart_type.name for chart_type in chart_types]: + return chart_type + return DEFAULT_SENSOR_CHART_TYPE + + class SensorUI(FlaskView): """ This view creates several new UI endpoints for viewing sensors. @@ -138,9 +159,16 @@ def get(self, id: int, **kwargs): if can_trigger_forecasts: earliest, latest = get_timerange([sensor.id]) has_enough_data = (latest - earliest) >= timedelta(days=2) + chart_types = [ + chart_type + for chart_type in SENSOR_CHART_TYPES.values() + if chart_type.applies_to(sensor) + ] return render_flexmeasures_template( "sensors/index.html", sensor=sensor, + chart_types=chart_types, + chart_type=initial_chart_type(chart_types), user_can_update_sensor=user_can_update(sensor), user_can_delete_sensor=user_can_delete(sensor), user_can_delete_sensor_data=user_can_access(sensor, "delete-data"),