Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a4434a9
Decide in one place which graphs the Charge Points chart draws below …
Flix6x Nov 2, 2026
781e40b
Add a registry of asset chart types, and draw asset charts through it
Flix6x Nov 2, 2026
79c557a
Validate chart-type on an asset's chart endpoint against the registry…
Flix6x Nov 2, 2026
6a208e5
Let the asset page ask for its charts by their public chart type names
Flix6x Nov 2, 2026
6368c92
Add changelog entries for the public asset chart type names
Flix6x Nov 2, 2026
aa244db
Say in the default chart type's description that it leaves the chargi…
Flix6x Nov 2, 2026
bf2393a
Name the charge hub fixture after fresh_db, and give its site an owne…
Flix6x Nov 2, 2026
06ce6de
Test that the chart endpoint reads chart-type under its snake_case al…
Flix6x Nov 2, 2026
bb79aa2
Make the helper that finds charging-session sensors public, as the re…
Flix6x Nov 2, 2026
62387b3
Leave a Charge Points companion graph without sensors out of the layout
Flix6x Nov 2, 2026
370cac5
Hold the sensors of suggested graphs in plots too, in a chart type's …
Flix6x Nov 2, 2026
eeec4ab
Move the registry's tests to flexmeasures/data/tests, next to the oth…
Flix6x Nov 2, 2026
f9551e2
Keep validators passed to the chart type field, and call the legacy c…
Flix6x Nov 2, 2026
eeba47b
Name the deprecated legacy names too when an unknown asset chart type…
Flix6x Nov 2, 2026
76a0e3e
Render the asset page's chart picker from the registry of asset chart…
Flix6x Nov 2, 2026
8ff91f4
Add a changelog entry for the graphs the Charge Points chart now rest…
Flix6x Nov 2, 2026
63916eb
Let plugins register asset chart types, through the same function tha…
Flix6x Nov 2, 2026
477481a
Validate asset chart type names against the registry as it is when th…
Flix6x Nov 2, 2026
a3c2142
Document how a plugin registers an asset chart type, and call the cha…
Flix6x Nov 2, 2026
89aa2a1
Test that a chart type a plugin registers is accepted by the chart AP…
Flix6x Nov 2, 2026
ad19093
Move the changelog entries to v1.2.0, the release this is planned for
Flix6x Nov 2, 2026
1f614a3
Fill in the PR number in the changelog entries
Flix6x Nov 2, 2026
d17fb60
Merge origin/main, bringing in the chart type checks of #2801
Flix6x Nov 3, 2026
ee23f64
Let the fast chart take the public names of the sensor chart types, a…
Flix6x Nov 3, 2026
4c34250
Let plugins register sensor chart types, which get public names too
Flix6x Nov 3, 2026
8a7c03b
Document how plugins register sensor chart types
Flix6x Nov 3, 2026
7990439
Merge origin/main, bringing in #2790 (chart data per chart type)
Flix6x Nov 3, 2026
03becfe
Merge origin/main, bringing in the v1.1.0 release and the v1.2.0 chan…
Flix6x Nov 6, 2026
7c8ade2
Renumber the API change log section of this PR to v3.0-35
Flix6x Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions documentation/api/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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
""""""""""""""""""""""""""""

Expand Down
2 changes: 2 additions & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2803>`_]


v1.1.0 | November 05, 2026
Expand Down
64 changes: 64 additions & 0 deletions documentation/plugin/customisation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>/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.
20 changes: 12 additions & 8 deletions flexmeasures/api/ui/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 <https://github.com/SeitaBV/timely-beliefs/blob/main/timely_beliefs/docs/timing.md/#events-and-sensors>`_)
- "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 ``<div style="width: 100%;">`` to set a div width to 100%)
- "height" (an integer number of pixels; without it, FlexMeasures sets a default, currently 300)
"""
Expand All @@ -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))
Expand Down
122 changes: 104 additions & 18 deletions flexmeasures/api/ui/tests/test_sensor_chart_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
):
Expand All @@ -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"]
Expand All @@ -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):
Expand All @@ -195,16 +193,104 @@ 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(
files("flexmeasures.ui").joinpath("static/openapi-specs.json").read_text()
)
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)
Loading
Loading