Skip to content

Schedulers as data generators, and a generator on every automation - #2464

Open
Flix6x wants to merge 5 commits into
feat/2288-schedule-automationsfrom
feat/scheduler-as-data-generator
Open

Schedulers as data generators, and a generator on every automation#2464
Flix6x wants to merge 5 commits into
feat/2288-schedule-automationsfrom
feat/scheduler-as-data-generator

Conversation

@Flix6x

@Flix6x Flix6x commented Sep 2, 2026

Copy link
Copy Markdown
Member

Description

A scheduler's data source recorded only its class, version and author, so a single source described
every schedule that scheduler ever made, whatever it computed. It now also records the flex config
the scheduler computed under
, the way a reporter's and a forecaster's source records theirs, so a
schedule can be traced back to the configuration that produced it — and so a schedule automation has
a data generator to point at, like a forecast automation does.

Scheduler is a DataGenerator. Its config is the asset, plus the serialized flex-model and
flex-context. Timing stays out: start, end and resolution differ from run to run, which is
what DataGenerator._clean_parameters already says about parameters rather than config. The config
is snapshotted while still serialized, because a deserialized flex config holds sensors, quantities
and time series which do not survive a round trip.

No scheduler changes behaviour. resolve_flex_config returns the config as it was passed, and
StorageScheduler overrides it to merge in what the asset tree stores first. A scheduler which does
not read the asset tree — ProcessScheduler, or a plugin's — therefore keeps describing exactly what
it was handed, and keeps computing exactly what it did before.

One request stays one source. create_sequential_scheduling_job resolves the request's source
once and hands it to every device job. Without that, each device job would resolve a source from its
own slice of the flex-model, and a schedule could no longer be retrieved per device from the
request's job.

Every automation has a generator. generator_id is now required, and the check constraint
requiring it only for forecasts is gone. A schedule automation's generator is derived rather than
chosen: the scheduler follows from the asset and the config from the asset tree, so the runner
resolves it again on every run and moves the automation when either has changed. Editing an asset's
flex-model is a configuration change, and reads as one.

A schedule automation's flex config describes the site, not a moment. Because the schedule is
recomputed on every run, a field fixing a moment — soc-at-start, or a soc-targets entry with a
datetime — is refused when the automation is created, and the error names the field:

Error: flex-model[0].soc-targets[0] fixes a moment in time, so it cannot configure a recurring
schedule automation, which computes a fresh schedule on every run. Refer to a sensor instead of a
fixed value, or leave the field out.
  • Added changelog item in documentation/changelog.rst

Consequence for hosts

Schedules computed under different flex configs are now recorded by different data sources, where
one source per scheduler version used to record them all. A sensor can therefore carry schedules from
several sources, as it already could for forecasts.

Values describing a single moment stay out of that config, so a soc-at-start, or a soc-targets
entry at a given datetime, does not make every trigger a new data source. What does is a change to
what the site and its devices can do, such as a device's power-capacity. After such a change, the
sensor holds the schedule computed under each configuration, where the newer schedule used to
supersede the older one, so a chart draws both and the asset's KPIs total both, as they report what
the chart draws. Selecting a data source shows one configuration's schedule.

A stable configuration is unaffected — same config, same source, supersession as before. This is
called out as a warning in the changelog.

How to test

pytest flexmeasures/data flexmeasures/api flexmeasures/ui flexmeasures/cli

For the reviewer: check where your database is stamped

generator_id is created NOT NULL by 8ecec35b799c, so #2293's migration relaxing it and this
PR's restoring it cancelled out exactly. Both are removed here, along with the merge revision that
only existed to rejoin the relaxing branch, so the automation feature arrives in one migration rather
than three.

Three revisions that existed on these branches are therefore gone: 5a9c0e3b7d21, c63896a97a8e and
b8f4d2617ac9. A database stamped at exactly one of those cannot resolve its own
alembic_version any more and needs a flexmeasures db stamp
— likely if you ran the #2293 branch
recently. A database at 3e91c47b0a58 or later is fine, since Alembic only resolves the revision it
is stamped with and walks up from there. Check with flexmeasures db current before upgrading.

The resulting chain was exercised on a sandboxed Postgres: upgrade from an empty database to head,
downgrade, and upgrade again. The column ends NOT NULL, with no check constraint on
automation.

Discussion

This implements option 3.1 from the discussion on #2293, where the alternative of leaving
generator_id nullable was weighed. Two consequences were agreed there before the work started:
sources fragmenting product-wide rather than only inside automations, and automations being definable
on a strict subset of trigger messages.

Related items

Based on #2293 (this PR's base branch), which stays reviewable on its own. Part of the automations
story #2334.


Sign-off

  • I agree to contribute to the project under Apache 2 License.
  • To the best of my knowledge, the proposed patch is not based on code under GPL or another incompatible license.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Lp1bUhWjEQtyDbnvRZQgQs

…ator

A scheduler's data source recorded only its class, version and author, so one source described every
schedule that scheduler ever made, whatever it computed. It now also records the flex config the
scheduler computed under, the way a reporter's and a forecaster's source records theirs, so a
schedule can be traced back to the configuration that produced it.

`Scheduler` therefore subclasses `DataGenerator`, with a config of the asset and its serialized
flex-model and flex-context. Timing stays out of it: start, end and resolution differ from run to
run, which is what `DataGenerator._clean_parameters` already says about parameters. The config is
snapshotted while still serialized, because a deserialized flex config holds sensors, quantities and
time series which do not survive a round trip. `resolve_flex_config` returns the config as passed,
and `StorageScheduler` overrides it to merge in what the asset tree stores, so a scheduler which
does not read the asset tree keeps describing exactly what it was given.

One scheduling request stays one data source: `create_sequential_scheduling_job` resolves the
request's source once and hands it to each device job, so a schedule can still be retrieved per
device from the request's job, rather than each device job resolving a source from its own slice of
the flex-model.

A schedule automation now points at such a source, so `generator_id` is required for every
automation and the constraint requiring it only for forecasts is gone. That generator is derived
rather than chosen: the scheduler follows from the asset and the config from the asset tree, so the
runner resolves it again on every run and moves the automation when either has changed. For the same
reason, a schedule automation's flex config may only describe the site and its devices: a field
fixing a moment, such as `soc-at-start` or a `soc-targets` entry with a `datetime`, is refused when
the automation is created, naming the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp1bUhWjEQtyDbnvRZQgQs
Signed-off-by: F.N. Claessen <claessen@seita.nl>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There is a confirmed crash risk when an invalid data_source_id is provided to make_schedule, and several new/updated comments/docstrings violate the repo’s line-break-after-punctuation convention.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR makes scheduler provenance consistent with forecasters/reporters by treating Scheduler as a DataGenerator whose data source identity includes the serialized flex config it computed under, and by requiring every automation (including scheduling) to reference a generator data source.

Changes:

  • Promote Scheduler to a DataGenerator and persist scheduler data sources with a config snapshot (asset, flex-model, flex-context) so schedules can be traced to the configuration used.
  • Ensure sequential scheduling requests use a single resolved scheduler data source across all per-device jobs, and update tests to query schedules by the job’s source.
  • Make Automation.generator_id mandatory for all types, add migration enforcement, and derive scheduling automation generators on create/run (including rejecting “momentary” flex fields for recurring scheduling automations).
File summaries
File Description
flexmeasures/data/tests/test_scheduling_simultaneous.py Update schedule assertions to query beliefs by the job’s resolved scheduler data source
flexmeasures/data/tests/test_scheduling_jobs.py Fetch scheduler source from job metadata instead of looking up by name
flexmeasures/data/tests/test_automations_fresh_db.py Add scheduling-automation generator derivation helpers and new generator-following test
flexmeasures/data/services/scheduling.py Thread data_source_id through scheduling jobs; ensure sequential child jobs share one request-level source
flexmeasures/data/services/automations.py Resolve and persist scheduling automation generator per run; detect “momentary” flex-config fields
flexmeasures/data/schemas/scheduling/init.py Export scheduler-config schema from scheduling schema package
flexmeasures/data/schemas/scheduler_config.py New Marshmallow schema for scheduler config stored on scheduler data sources
flexmeasures/data/models/planning/storage.py Override scheduler config resolution to include asset-tree flex config before recording
flexmeasures/data/models/planning/init.py Make Scheduler a DataGenerator; implement config capture and config-hashed data sources
flexmeasures/data/models/automations.py Remove forecast-only generator constraint and require generator_id for all automations
flexmeasures/data/migrations/versions/b8f4d2617ac9_require_a_generator_on_every_automation.py New migration to enforce non-null generator and drop old forecast-only constraint
flexmeasures/cli/tests/test_automations.py Add CLI test for rejecting “momentary” flex fields; assert scheduling automations now have a generator
flexmeasures/cli/data_add.py Derive and validate scheduling automation generators at creation time
flexmeasures/api/v3_0/tests/test_automations_api_fresh_db.py Ensure API fixtures create scheduling automations with generator_id
documentation/features/automations.rst Document scheduling automation generator derivation and “momentary” flex-field rejection
documentation/cli/change_log.rst CLI changelog entry for scheduling automation parameter validation behavior
documentation/changelog.rst Add warning about scheduler-source fragmentation by flex config; add feature entry
Review details

Suppressed comments (1)

flexmeasures/data/services/scheduling.py:908

  • If an invalid data_source_id is passed, db.session.get(DataSource, ...) returns None and the next block will crash at data_source.id; fail fast with a clear error (or fall back to scheduler.data_source) to avoid an AttributeError and silent mis-attribution.
    if data_source_id is not None:
        data_source = db.session.get(DataSource, data_source_id)
    else:
        data_source = scheduler.data_source
  • Files reviewed: 17/17 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread flexmeasures/data/models/automations.py Outdated
Comment on lines +36 to +40
The recurrence is defined by a cron string. Every automation has a data generator,
linked through a data source: a forecaster and its configuration for a forecast automation,
and a scheduler and the flex config it computes under for a schedule automation.
A forecast automation's generator is chosen when it is created, while a schedule automation's
follows from its asset, so the runner resolves that one afresh on every run.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflowed, along with the two comments in scheduling.py and the one in test_scheduling_simultaneous.py: each physical line now ends at punctuation.

Comment on lines +475 to +478
# A scheduling request is one run of one generator, so all of its device jobs record their
# schedules under one data source, describing the request's own configuration.
# Without this, each device job would resolve a source of its own, from its own slice of the
# flex-model, and a schedule could no longer be retrieved per device from the request's job.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflowed both, so each line ends at punctuation. The suppressed finding on line 908 was right too: make_schedule now raises with the source's id when a job is told to record under a data source that no longer exists, rather than an AttributeError one frame later.

Comment on lines +83 to +85
# Get power and SoC values, from the source this job wrote with.
# A scheduler's data source records the flex config it computed under, so these sensors can also
# carry schedules computed under another config, by another job, from another source.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reflowed.

Comment thread documentation/changelog.rst Outdated

.. warning:: A scheduler's data source now also records the flex config it computed under, where previously one data source per scheduler version recorded every schedule that scheduler made.
Schedules computed under different flex configs are therefore recorded by different data sources, and a sensor can carry schedules from several of them, as it already could for forecasts.
Charts then show one series per configuration used, and a query which aggregates a sensor's schedules should filter by source.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a query which aggregates a sensor's schedules should filter by source.

Why? What does this mean?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — it stated a conclusion without the reasoning, and with the fix above it is also narrower than
it was. Rewritten to say what actually happens:

After such a change, a sensor holds a schedule for the same period from each configuration, so a
chart draws both, and a query which sums a sensor's scheduled power over that period adds both up
unless it selects a source.

So the concrete case is: you change a battery's power-capacity and reschedule the same period. The
sensor then carries two schedules for that period, one per configuration, and search_beliefs()
returns both, because they come from different sources. Summing them without selecting a source
double-counts. This is the same thing that already happens for forecasts when a forecaster's
configuration changes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now reads as if we're introducing a bug, because it never (afaik) makes sense to sum over such schedules recorded on the same sensor. Are we introducing a bug? Or is our use of search_beliefs for summing already robust against it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I traced it rather than guessed, and the answer is: no new bug, but the wording implied one.
Rewritten in 3fa5824.

What FlexMeasures does today. Sensor.search_beliefs defaults to
most_recent_beliefs_only=True, which is per source, and use_latest_version_per_event=True,
which collapses sources sharing a (name, type, model) — but only within the same belief time,
unless one_deterministic_belief_per_event is set. Two schedules computed at different moments under
different configs therefore both survive. The KPI endpoint reads exactly that, and sums it.

That is deliberate, not an oversight. test_kpi_reports_what_the_chart_draws pins it:

A KPI is read beside the chart, so it must describe the same beliefs.

and it asserts a total across two sources. So a KPI totals whatever the chart draws, including one
value per source — which is already the case today for any sensor reported by two sources, such as a
forecaster and a scheduler, or two forecasters with different configs. We are not changing that path,
and not breaking it.

What we do change is that this situation becomes reachable for a scheduled sensor, where it was
not before: change a device's power-capacity, reschedule the same period, and the sensor keeps both
schedules where the newer one used to supersede the older. And you are right that summing those two
is meaningless — but so is drawing them as one series, and the chart drawing both is the honest
picture of what was computed.

So the warning no longer talks about queries that sum. It says what changed:

After such a change, a sensor holds the schedule computed under each configuration, where the newer
schedule used to supersede the older one, so a chart of that sensor draws both, and the asset's KPIs
total both, as they report what the chart draws.
Select a data source to see the schedule computed under one configuration.

If you would rather a KPI never totalled two configurations' schedules, the lever is one argument —
one_deterministic_belief_per_event=True in the KPI query, which keeps the highest-priority source
per event. I have left it alone, because it would also change what a KPI reports for the multi-source
sensors that exist today, and it would stop agreeing with the chart. Happy to do it, here or in its
own issue, if you think a KPI should pick one source.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opened #2471.

Comment thread documentation/changelog.rst
Comment thread documentation/features/automations.rst Outdated
Comment thread flexmeasures/data/schemas/scheduler_config.py Outdated
Flix6x and others added 2 commits September 3, 2026 13:09
Keep values that describe one moment out of a scheduler's data source. A `soc-at-start`, or a
`soc-targets` entry at a given datetime, differs on every trigger, so recording it would have made
every schedule the work of a brand new data source. Only what the site and its devices can do now
tells one scheduler source from another. The rule that already refused such fields on a schedule
automation is the same one, so both now live next to the config schema they are about.

That schema moves back in with the other scheduling schemas, into a submodule of its own. The
circular import which had pushed it out is fixed at its root instead: three scheduling schema modules
imported `Sensor` and `Asset` from the `flexmeasures` package root, which is still initialising while
they load, so they now import from the modules that define them.

The migration relaxing the generator constraint and the one restoring it cancel out, and both were
unreleased, so they are gone, along with the merge revision that only existed to rejoin the relaxing
one. `generator_id` keeps the NOT NULL it was created with, the type rename no longer has a
constraint to recreate, and the automation feature arrives in one migration rather than three.

Also, fail with the source's id when a job is told to record under a data source that no longer
exists, rather than an AttributeError one frame later, and reflow the comments and docstrings which
broke mid-phrase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp1bUhWjEQtyDbnvRZQgQs
Signed-off-by: F.N. Claessen <claessen@seita.nl>
… sensor

The warning read as if summing two schedules of one period were a thing anyone would want to do,
which made it sound like a defect rather than a change in provenance. It now says what actually
changes: the newer schedule used to supersede the older one, and now both are kept, so a chart draws
both and the asset's KPIs total both, because they deliberately report what the chart draws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp1bUhWjEQtyDbnvRZQgQs
Signed-off-by: F.N. Claessen <claessen@seita.nl>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Scheduler data-source identity is still vulnerable to unintended fragmentation due to unstable serialization of non-JSON flex-config values and incomplete dropping of momentary fields.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread flexmeasures/data/models/planning/__init__.py Outdated
Comment thread flexmeasures/data/schemas/scheduling/config.py
Two ways the recorded config could differ while describing the same configuration, both from the
Copilot review.

A caller which deserialized the flex config first hands over sensors and assets, which were recorded
by how they print. A sensor prints as its name, so renaming one described a different configuration,
and two sensors sharing a name described the same one. They are now recorded by their id, which is
what the serialized flex config names them by.

A single moment may be written as one mapping or as a list of them, and stripping the momentary
values left a null behind in the first case and an empty list in the second, so the same
configuration looked like three different ones depending on how it was written. A field that
stripping empties is now left out altogether, which is what leaving it out of the trigger message
does too. A field that arrived empty stays as it was given, and a list with static entries beside
momentary ones keeps them.

The snapshot no longer deep-copies. It goes straight through the JSON-safe encoder, which yields
plain structures, where the copy used to carry sensors along and be read after a commit had expired
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp1bUhWjEQtyDbnvRZQgQs
Signed-off-by: F.N. Claessen <claessen@seita.nl>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Sequential scheduling can enqueue child jobs referencing a newly-created (but uncommitted) scheduler data source ID, which can race and fail when workers try to look it up.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

flexmeasures/data/models/planning/init.py:81

  • This class docstring still wraps mid-phrase (e.g. the sentence split between the lines ending with "when" and starting with "knowledge"). The repo convention is to break docstring/comment lines only after punctuation; please reflow these sentences so each physical line ends with punctuation.
  • Files reviewed: 25/25 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +479 to +488
request_scheduler = get_scheduler_instance(
scheduler_class=scheduler_class,
asset_or_sensor=asset,
scheduler_params={
**scheduler_kwargs,
"flex_model": MultiSensorFlexModelSchema(many=True).dump(flex_model),
},
)
data_source_id = request_scheduler.data_source.id

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and it is worse than a race — fixed in 5d6540d.

FlexMeasures does not auto-commit: flexmeasures/data/transactional.py says so outright ("Our policy
is that we don't auto-commit"), and the schedule trigger endpoint does not commit either. The only
commit in this flow is at the end of make_schedule, in the worker. So the data source resolved while
enqueueing was never going to reach the workers at all — not merely late. The tests did not catch it
because work_on_rq runs the worker in-process, on the same session.

The device jobs now carry the request's configuration rather than a data source id, and each worker
resolves the source from it and commits, the way make_schedule already does for everything else it
writes. One request still describes one configuration, so its device jobs still share one source, and
a schedule can still be retrieved per device from the request's job.

Scheduler.record_config is the seam. The data_source_id argument and its lookup are gone, which
also retires the not-found guard from the last round.

A test pins both halves, and fails without the fix:

assert all(config == configs[0] for config in configs)
# Enqueueing wrote no data source of its own, which is what it must not rely on.
assert db.session.scalars(select(DataSource).filter_by(type="scheduler")).all() == scheduler_sources_before

Also reflowed the Scheduler class docstring from your suppressed comment. Full suite: 1794 passed.

…e id

FlexMeasures does not auto-commit the session of the request that enqueues a scheduling job (see
`flexmeasures.data.transactional`), and the schedule trigger endpoint does not commit either. A data
source resolved while enqueueing therefore lives in a transaction the workers never see, so handing
its id to the device jobs of a sequential schedule would have failed to find it.

The jobs now carry the request's configuration instead, and each worker resolves the data source from
it and commits, as `make_schedule` already does for everything else it writes. The device jobs of one
request still describe one configuration, so they still share one source.

A test pins that enqueueing writes no data source of its own, and that the device jobs carry the same
configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp1bUhWjEQtyDbnvRZQgQs
Signed-off-by: F.N. Claessen <claessen@seita.nl>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are at least two concrete issues to address (a docstring line-break convention violation and a new test that can leak queued jobs into later tests, making the suite order-dependent).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 26/26 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +61 to +65
# Enqueueing wrote no data source of its own, which is what it must not rely on.
assert (
db.session.scalars(select(DataSource).filter_by(type="scheduler")).all()
== scheduler_sources_before
)

Furthermore, the scheduler needs to have knowledge about the asset's flexibility model (under what constraints
can the schedule be optimized?) and the system's flexibility context (which other sensors are relevant, e.g. prices).
Furthermore, the scheduler needs to have knowledge about the asset's flexibility model
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants