Schedulers as data generators, and a generator on every automation - #2464
Schedulers as data generators, and a generator on every automation#2464Flix6x wants to merge 5 commits into
Conversation
…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>
Documentation build overview
19 files changed ·
|
There was a problem hiding this comment.
🟡 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
Schedulerto aDataGeneratorand persist scheduler data sources with aconfigsnapshot (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_idmandatory 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.
| 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. |
There was a problem hiding this comment.
Reflowed, along with the two comments in scheduling.py and the one in test_scheduling_simultaneous.py: each physical line now ends at punctuation.
| # 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. |
There was a problem hiding this comment.
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.
| # 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. |
|
|
||
| .. 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. |
There was a problem hiding this comment.
a query which aggregates a sensor's schedules should filter by source.
Why? What does this mean?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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>
There was a problem hiding this comment.
🟡 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
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>
There was a problem hiding this comment.
🟡 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
| 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 | ||
|
|
There was a problem hiding this comment.
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_beforeAlso 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>
There was a problem hiding this comment.
🟡 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
| # 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 |
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.
Scheduleris aDataGenerator. Its config is the asset, plus the serializedflex-modelandflex-context. Timing stays out:start,endandresolutiondiffer from run to run, which iswhat
DataGenerator._clean_parametersalready says about parameters rather than config. The configis 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_configreturns the config as it was passed, andStorageScheduleroverrides it to merge in what the asset tree stores first. A scheduler which doesnot read the asset tree —
ProcessScheduler, or a plugin's — therefore keeps describing exactly whatit was handed, and keeps computing exactly what it did before.
One request stays one source.
create_sequential_scheduling_jobresolves the request's sourceonce 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_idis now required, and the check constraintrequiring 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 asoc-targetsentry with adatetime— is refused when the automation is created, and the error names the field:documentation/changelog.rstConsequence 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 asoc-targetsentry 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, thesensor 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
For the reviewer: check where your database is stamped
generator_idis createdNOT NULLby8ecec35b799c, so #2293's migration relaxing it and thisPR'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,c63896a97a8eandb8f4d2617ac9. A database stamped at exactly one of those cannot resolve its ownalembic_versionany more and needs aflexmeasures db stamp— likely if you ran the #2293 branchrecently. A database at
3e91c47b0a58or later is fine, since Alembic only resolves the revision itis stamped with and walks up from there. Check with
flexmeasures db currentbefore upgrading.The resulting chain was exercised on a sandboxed Postgres:
upgradefrom an empty database to head,downgrade, andupgradeagain. The column endsNOT NULL, with no check constraint onautomation.Discussion
This implements option 3.1 from the discussion on #2293, where the alternative of leaving
generator_idnullable 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
🤖 Generated with Claude Code
https://claude.ai/code/session_01Lp1bUhWjEQtyDbnvRZQgQs