Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ New features
* Automations - first roundtrip for forecasts: recurring tasks defined per asset, managed with new CLI commands (``flexmeasures add|edit|delete automation``), run by ``flexmeasures jobs run-automations``, and viewable in a new UI page and API endpoints (``[GET] /assets/(id)/automations``); jobs now also record whether they were created via the CLI, the API or an automation [see `PR #2290 <https://www.github.com/FlexMeasures/flexmeasures/pull/2290>`_]
* Automations can also compute schedules on a recurring basis (``flexmeasures add automation --type schedules``), with the schedule start defaulting to each run's time [see `PR #2293 <https://www.github.com/FlexMeasures/flexmeasures/pull/2293>`_]
* Automations can be created, edited and deleted in the UI and through new API endpoints (``[POST|PATCH|DELETE] /assets/(id)/automations``), by account admins and consultants [see `PR #2294 <https://www.github.com/FlexMeasures/flexmeasures/pull/2294>`_]
* Reports can run as background jobs (``flexmeasures add report --as-job``, processed by workers of the new ``reporting`` queue) and be computed on a recurring basis by automations, with a rolling report window expressed as Pandas offsets or defaulting to the last cron period [see `PR #2297 <https://www.github.com/FlexMeasures/flexmeasures/pull/2297>`_]
* Breaking behaviour change: the top-level flex-context's ``relax-constraints`` field now defaults to ``True`` (matching the default already used within each ``commodities`` entry), so constraint violations are softly penalized by default instead of being hard constraints, unless explicitly set to ``False`` [see `PR #2172 <https://www.github.com/FlexMeasures/flexmeasures/pull/2172>`_]
* In the UI, asset and sensor lists can be filtered by ID prefix through API-backed search fields [see `PR #2231 <https://www.github.com/FlexMeasures/flexmeasures/pull/2231>`_]
* Support configurable lower and upper bounds and snapping for forecast post-processing [see `PR #2273 <https://www.github.com/FlexMeasures/flexmeasures/pull/2273>`_]
Expand Down
3 changes: 2 additions & 1 deletion documentation/cli/change_log.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ since v1.0.0 | July XX, 2026

* Add ``flexmeasures edit secret`` to store an encrypted secret on an account or asset.
* Add ``flexmeasures delete secret`` to remove an encrypted secret from an account or asset.
* Add ``flexmeasures add automation``, ``flexmeasures edit automation`` and ``flexmeasures delete automation`` to manage automations (recurring tasks on an asset, computing forecasts or schedules).
* Add ``flexmeasures add automation``, ``flexmeasures edit automation`` and ``flexmeasures delete automation`` to manage automations (recurring tasks on an asset, computing forecasts, schedules or reports).
* Add an ``--as-job`` flag to ``flexmeasures add report``, to queue a reporting job (processed by workers of the new ``reporting`` queue) instead of computing directly.
* Add ``flexmeasures jobs run-automations`` to queue jobs for all automations that are due to run this minute (run this once per minute, e.g. via cron).

since v0.33.0 | June 01, 2026
Expand Down
2 changes: 1 addition & 1 deletion documentation/cli/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ of which some are referred to in this documentation.
``flexmeasures add annotation`` Add annotation to accounts, assets and/or sensors.
``flexmeasures add toy-account`` Create a toy account, for tutorials and trying things.
``flexmeasures add report`` Create a report.
``flexmeasures add automation`` Add an automation: a recurring task (computing forecasts or schedules) on an asset.
``flexmeasures add automation`` Add an automation: a recurring task (computing forecasts, schedules or reports) on an asset.
================================================= =======================================


Expand Down
56 changes: 56 additions & 0 deletions documentation/features/automations.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
.. _automations:

Automations
============

Hosts and users often want the three main FlexMeasures features — :ref:`forecasting`, :ref:`scheduling` and :ref:`reporting` — to run on a recurring basis, across larger numbers of sites.
*Automations* make that a first-class concept: an automation is a recurring task defined on an asset, and each time it runs, it queues jobs.

An automation consists of:

- a **type**: ``forecasts``, ``schedules`` or ``reports``;
- a **recurrence**: a cron string (e.g. ``"0 6 * * *"`` for daily at 6 AM), interpreted in the ``FLEXMEASURES_TIMEZONE``;
- a **data generator** (for forecasts and reports): the forecaster or reporter class and its configuration, stored on a data source.
The data source stays the same across runs, so all results the automation produces attribute to one steady source;
- **parameters**: what to compute on each run, validated by the same schema the CLI and API use for one-off runs.
Timing parameters are resolved freshly on each run, so a recurring automation always computes fresh periods
(see the type-specific sections below for the exact rules);
- an **activation status**: only active automations run.

Managing automations
--------------------

Automations can be managed in three ways:

- **CLI**: ``flexmeasures add automation``, ``flexmeasures edit automation`` (name, cron string, activation status) and ``flexmeasures delete automation``.
- **API**: list and inspect with ``[GET] /assets/(id)/automations`` and ``[GET] /assets/(id)/automations/(automation_id)``;
create, update and delete with ``[POST|PATCH|DELETE]`` on the same paths (see the `API documentation <../api/v3_0.html>`_).
- **UI**: each asset has an *Automations* page (in the breadcrumbs dropdown), with a tab per automation type.
It lists each automation's recurrence and recent job counts, and lets you create, (de)activate and delete automations.

Creating, updating and deleting automations requires account admin or consultant rights, and is recorded in the asset's audit log.

Running automations
--------------------

An automation is due whenever its cron string matches the current minute. To actually run due automations, let a cron job execute the following command once per minute:

.. code-block:: bash

* * * * * flexmeasures jobs run-automations

Each due automation then queues its jobs — so make sure workers are processing the relevant queues (``forecasting``, ``scheduling`` and/or ``reporting``, see :ref:`redis-queue`).
A Redis-based guard prevents queueing jobs twice if the command happens to run more than once within the same minute.

Jobs record how they were created (via the CLI, the API or an automation), which is shown in the *Created Via* column
of the jobs table on the asset's status page, where recent jobs are listed.

Automating each feature
-----------------------

The parameters stored on an automation follow the same schemas as one-off CLI/API calls, with type-specific rules for resolving timing on each run:

- :ref:`automating_forecasts` — forecast parameters; the forecast start defaults to the run time.
- :ref:`automating_schedules` — a schedule trigger message; omit ``start`` to schedule from the run time.
- :ref:`automating_reports` — report parameters; use ``start-offset``/``end-offset`` (Pandas offsets) for a rolling window,
or omit timing fields to report on the period since the automation's actual last run.
4 changes: 2 additions & 2 deletions documentation/features/forecasting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ In `this weather forecast plugin <https://github.com/flexmeasures/flexmeasures-w
Automating forecasts
--------------------

Instead of asking for forecasts one at a time, you can set up an *automation*: a recurring task defined on an asset.
Instead of asking for forecasts one at a time, you can set up an *automation*: a recurring task defined on an asset (see :ref:`automations` for the full concept, including how to manage and run automations).
On each run, the automation queues forecasting jobs (so make sure a worker is processing the ``forecasting`` queue, see :ref:`redis-queue`).
When the automation was created, its forecast parameters (see above) were stored, and validated with the same schema that the CLI and API use.
Timing parameters are resolved on each run — for instance, the forecast start defaults to the time the automation runs, so each run produces fresh forecasts.
Expand Down Expand Up @@ -207,4 +207,4 @@ Automations defined on an asset can be viewed on the asset's *Automations* page
Account admins and consultants can also create, (de)activate and delete automations right there on the page,
or through the API (`[POST] /assets/(id)/automations`, `[PATCH] /assets/(id)/automations/(automation_id)` and `[DELETE] /assets/(id)/automations/(automation_id)`).

Schedules can be automated in the same way — see :ref:`automating_schedules`.
Schedules and reports can be automated in the same way — see :ref:`automating_schedules` and :ref:`automating_reports`.
26 changes: 25 additions & 1 deletion documentation/features/reporting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,28 @@ The input sensor stores the power/energy flow, and the output sensor will store
Here, the ``ProfitOrLossReporter`` used as source (with Id 6) is the one we configured above.
With the offsets, we control the timing ― we indicate that we want the new report to encompass the day of tomorrow (see Pandas offset strings).

The report sensor will now store all costs which we know will be made tomorrow by the schedule.
The report sensor will now store all costs which we know will be made tomorrow by the schedule.

.. _automating_reports:

Automating reports
--------------------

Reports can be queued as background jobs (add ``--as-job`` to ``flexmeasures add report``, and let a worker process the ``reporting`` queue, see :ref:`redis-queue`),
and computed on a recurring basis by an *automation* defined on the asset (see :ref:`automations` for the full concept, including how to manage and run automations).

The reporter and its configuration are stored on a data source (steady across runs, so all report results attribute to the same source),
while the report parameters are stored on the automation itself and their timing is resolved freshly on each run:

- Use ``start-offset`` and/or ``end-offset`` fields (comma-separated Pandas offsets, like the CLI options above) for a rolling window relative to the run time,
in the timezone of the first output sensor. For instance, ``"start-offset": "-1D,DB"`` with ``"end-offset": "DB"`` reports on the whole previous day.
- Omit timing fields entirely to report on the period since the automation's actual last run
(falling back to the last cron period — from the previous cron fire time until the run time — when no last run is known, e.g. on the first run).
- Absolute ``start``/``end`` fields are also accepted, but draw a warning, as each run would then compute the same period.

For example, this automation computes a report over each past day, every morning at 1 AM:

.. code-block:: bash

flexmeasures add automation --asset 3 --name "Daily aggregation report" --cron "0 1 * * *" --type reports \
--reporter PandasReporter --config reporter-config.yml --parameters report-parameters.yml
2 changes: 1 addition & 1 deletion documentation/features/scheduling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,7 @@ Here are some thoughts on further innovation:
Automating schedules
--------------------

Like forecasts, schedules can be computed on a recurring basis by an *automation* defined on the asset (see :ref:`automating_forecasts` for the full introduction, including how to run automations).
Like forecasts, schedules can be computed on a recurring basis by an *automation* defined on the asset (see :ref:`automations` for the full concept, including how to manage and run automations).
The automation's parameters form a schedule trigger message, as accepted by the `[POST] /assets/(id)/schedules/trigger <../api/v3_0.html>`_ API endpoint (without the asset id).
Omit the ``start`` field to schedule from the run time on each run (floored to the ``resolution`` field, if given).
As usual, the flex-context and flex-model can also (partly) live on the asset itself, in which case a minimal trigger message suffices.
Expand Down
2 changes: 1 addition & 1 deletion documentation/host/queues.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Here is how to run one worker for each kind of job (in separate terminals):

.. code-block:: bash

$ flexmeasures jobs run-worker --name our-only-worker --queue forecasting|scheduling|ingestion
$ flexmeasures jobs run-worker --name our-only-worker --queue forecasting|scheduling|ingestion|reporting

Running multiple workers in parallel might be a great idea.

Expand Down
1 change: 1 addition & 0 deletions documentation/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ In :ref:`getting_started`, we have some helpful tips how to dive into this docum
features/scheduling
features/forecasting
features/reporting
features/automations

.. toctree::
:caption: Tutorials
Expand Down
93 changes: 92 additions & 1 deletion flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,90 @@
sensors_schema = SensorSchema(many=True)


def _forecast_automation_sensor_refs(
parameters: dict, config: dict
) -> tuple[list, list]:
read_refs = [parameters.get("sensor")]
for key in ("regressors", "future-regressors", "past-regressors"):
read_refs.extend(config.get(key) or [])
write_refs = [parameters.get("sensor-to-save") or parameters.get("sensor")]
return read_refs, write_refs


def _report_automation_sensor_refs(parameters: dict) -> tuple[list, list]:
read_refs = [
entry.get("sensor")
for entry in parameters.get("input") or []
if isinstance(entry, dict)
]
write_refs = [
entry.get("sensor")
for entry in parameters.get("output") or []
if isinstance(entry, dict)
]
return read_refs, write_refs


def _schedule_automation_sensor_refs(parameters: dict) -> tuple[list, list]:
read_refs: list = []
write_refs: list = []
for entry in parameters.get("flex-model") or []:
if isinstance(entry, dict):
write_refs.append(entry.get("sensor"))
if isinstance(entry.get("state-of-charge"), dict):
read_refs.append(entry["state-of-charge"].get("sensor"))
flex_context = parameters.get("flex-context") or {}
if isinstance(flex_context, dict):
for value in flex_context.values():
if isinstance(value, dict):
read_refs.append(value.get("sensor"))
elif isinstance(value, list):
for item in value:
read_refs.append(
item.get("sensor") if isinstance(item, dict) else item
)
return read_refs, write_refs


def _check_automation_sensor_access(
automation_type: str, parameters: dict, config: dict
):
"""Check that the current user may read every sensor referenced in an
automation's parameters/config, and record data on the sensors the
automation would write to.

Sensor references that do not resolve are skipped here; schema validation
of the parameters rejects them later.

:raises Forbidden: if any check fails (answered with 403).
"""
parameters = parameters or {}
config = config or {}
if automation_type == "forecasts":
read_refs, write_refs = _forecast_automation_sensor_refs(parameters, config)
elif automation_type == "reports":
read_refs, write_refs = _report_automation_sensor_refs(parameters)
elif automation_type == "schedules":
read_refs, write_refs = _schedule_automation_sensor_refs(parameters)
else:
read_refs, write_refs = [], []

def resolve(value) -> Sensor | None:
try:
return db.session.get(Sensor, int(value))
except (TypeError, ValueError):
return None

for value in read_refs:
sensor = resolve(value)
if sensor is not None:
check_access(sensor, "read")
for value in write_refs:
sensor = resolve(value)
if sensor is not None:
check_access(sensor, "create-children")


def sensor_term_filter(term: str):
filters = [Sensor.name.ilike(f"%{term}%")]
if term.isdecimal():
Expand Down Expand Up @@ -1371,6 +1455,13 @@ def post_automation(self, id: int, asset: GenericAsset):
automation_data = AutomationCreationSchema().load(body)
except ValidationError as e:
return unprocessable_entity(e.messages)
# Guard against referencing sensors outside the caller's reach
# (raises Forbidden, answered with 403)
_check_automation_sensor_access(
automation_data["type"],
automation_data["parameters"],
automation_data["config"],
)
try:
automation, warnings = create_automation(
asset=asset,
Expand All @@ -1379,7 +1470,7 @@ def post_automation(self, id: int, asset: GenericAsset):
automation_type=automation_data["type"],
active=automation_data["active"],
parameters=automation_data["parameters"],
forecaster_class=automation_data["forecaster"],
generator_class=automation_data["generator"],
config=automation_data["config"],
origin="API",
)
Expand Down
50 changes: 50 additions & 0 deletions flexmeasures/api/v3_0/tests/test_automations_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
from flask import url_for
from sqlalchemy import select

from flexmeasures.data.models.automations import Automation

Expand Down Expand Up @@ -192,6 +193,55 @@ def test_post_automation(
db.session.flush()


@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
def test_post_automation_with_foreign_sensor(
app,
db,
setup_accounts,
add_battery_assets,
requesting_user,
):
"""Referencing a sensor outside the caller's reach is forbidden."""
from datetime import timedelta

from flexmeasures.data.models.generic_assets import GenericAsset
from flexmeasures.data.models.time_series import Sensor

battery = add_battery_assets["Test battery"]
foreign_asset = GenericAsset(
name="Foreign asset",
generic_asset_type=battery.generic_asset_type,
owner=setup_accounts["Dummy"],
)
foreign_sensor = Sensor(
"foreign power",
generic_asset=foreign_asset,
event_resolution=timedelta(minutes=15),
unit="MW",
)
db.session.add(foreign_sensor)
db.session.flush()
with app.test_client() as client:
response = client.post(
url_for("AssetAPI:post_automation", id=battery.id),
json={
"name": "Sneaky forecasts",
"cronstr": "0 6 * * *",
"type": "forecasts",
"parameters": {"sensor": foreign_sensor.id},
},
)
assert response.status_code == 403
assert (
db.session.execute(
select(Automation).filter_by(name="Sneaky forecasts")
).scalar_one_or_none()
is None
)


@pytest.mark.parametrize(
"requesting_user", ["test_prosumer_user_2@seita.nl"], indirect=True
)
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def create( # noqa C901
forecasting=Queue(connection=redis_conn, name="forecasting"),
scheduling=Queue(connection=redis_conn, name="scheduling"),
ingestion=Queue(connection=redis_conn, name="ingestion"),
# reporting=Queue(connection=redis_conn, name="reporting"),
reporting=Queue(connection=redis_conn, name="reporting"),
# labelling=Queue(connection=redis_conn, name="labelling"),
# alerting=Queue(connection=redis_conn, name="alerting"),
)
Expand Down
Loading
Loading