Add soft constraint analysis (unresolved/resolved SoC constraints) to scheduling job results#2072
Conversation
Documentation build overview
27 files changed ·
|
…heduler - Add SchedulingJobResult dataclass (JSON-serializable) to store job results - Modify _build_soc_schedule to also return per-device MWh SoC schedules, including for devices with soc-minima/soc-maxima constraints but no SoC sensor - Add _compute_unresolved_targets to find the first violated soc-minima/soc-maxima - StorageScheduler.compute() now includes scheduling_result in return_multiple output - make_schedule() stores SchedulingJobResult in rq_job.meta["scheduling_result"] - get_schedule API endpoint returns scheduling_result next to scheduler_info - Document that soc-targets are hard constraints (not reported in unresolved_targets) - Add tests: test_unresolved_targets_soc_minima and test_unresolved_targets_none_when_met - Add changelog entry for PR #2072 Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/710e6bc9-87d9-4238-9c3f-c79a445aff3e Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com>
- Add docstring to SchedulingJobResult.unresolved_targets documenting the dict structure
- Rename has_soc_constraints to has_soc_minima_maxima for clarity
- Move SchedulingJobResult import to module level in storage.py
- Use self.round_to_decimals for delta precision (defaults to 6)
- Return None (not {}) from get_schedule when scheduling_result not in job meta
- Mark scheduling_result as nullable in OpenAPI schema
- Improve test docstring to clarify efficiency assumptions
Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/710e6bc9-87d9-4238-9c3f-c79a445aff3e
Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com>
- Fix multi-device ordering bug: now finds earliest violation across ALL devices instead of stopping at first device with a violation - Normalize violation datetime to UTC in isoformat() output - Round soc_schedule_mwh to round_to_decimals precision before comparison to avoid epsilon false positives from floating-point arithmetic - Add test_unresolved_targets_soc_maxima to cover the soc-maxima violation path Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/710e6bc9-87d9-4238-9c3f-c79a445aff3e Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com>
…sion comment - Define SCHEDULING_RESULT_KEY constant in storage.py to avoid magic strings - Use the constant in compute(), make_schedule(), and get_schedule API - Add explanatory comment for round_to_decimals fallback precision Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/710e6bc9-87d9-4238-9c3f-c79a445aff3e Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com>
Flix6x
left a comment
There was a problem hiding this comment.
This time run appropriate specialists to check/do the coding. I'm also missing updates to the documentation. Agent instruction files, incl. AGENTS.md, must be updated with lessons learned from each PR review.
Context: - Review feedback on the 'compute first unmet targets' feature requested per-sensor tracking, always-positive deltas with units, and omitting scheduling_result from the API response for legacy jobs. Change: - SchedulingJobResult.unresolved_targets is now keyed by sensor ID string (SoC sensor if available, else power sensor), with per-device constraint violations; an empty dict means all targets were met. - _compute_unresolved_targets returns per-device violations only (no cross- device earliest logic); delta is always positive in kWh as a string. - sensors.py omits scheduling_result from the response entirely for legacy jobs (was returning null); OpenAPI description updated accordingly. - Tests updated to assert the new structure and exact delta values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com>
…_targets Change: - Skip devices where neither SoC sensor nor power sensor is available, rather than crashing with AttributeError on None.id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com>
Addressed in 3649804. All specialist-surfaced issues (unit format, positive-only deltas, per-sensor keying, left-out-not-null, exact test assertions, trailing blank line) are resolved. AGENTS.md will be updated with lessons learned as part of session wrap-up. |
There was a problem hiding this comment.
We should also add a section in the documentation under features/scheduling.rst. Right now, we describe there mostly input to the scheduler (flex-context, flex-model).
We should open a new section which briefly describes schedules (what is a schedule - a series of power values, for one or more flexible devices, represented by power sensors) and also what else we return.
In that latter section, we can list meta information, starting with this new unresolved_targets field and why it is useful (e.g. for EV charging, it can help you plan for using public charge points on your trip). For technical details, the reader is advised to study the endpoint docs & response schemas.
Flix6x
left a comment
There was a problem hiding this comment.
- Use only state-of-charge sensors as keys, not power sensors. (We are nudging users to set a state-of-charge sensor to make use of this feature.)
- Use
"unmet"instead of"delta". - Add a
"resolved_targets"field noting the"margin".
# Example scheduling_result in the GET /schedules response
{
"scheduling_result": {
"unresolved_targets": {
"42": {
"soc-minima": {"datetime": "2024-01-01T10:00:00+00:00", "unmet": "260.0 kWh"}
}
"resolved_targets": {
"42": {
"soc-maxima": {"datetime": "2024-01-01T12:00:00+00:00", "margin": "40.0 kWh"}
}
}
}
}
Also follow-up on nhoening's comment.
… result Context: - Review feedback on the "compute first unmet targets" feature - unresolved_targets previously used "delta" key and fell back to power sensor when no SoC sensor was set Change: - Rename "delta" → "unmet" in unresolved_targets entries for clarity - Add resolved_targets field: tracks soft constraints that WERE met, reporting the tightest (smallest margin) slot per sensor - Only use state-of-charge sensors as keys; skip devices without one - _compute_unresolved_targets now returns (unresolved, resolved) tuple - Update to_dict/from_dict to include resolved_targets - Update OpenAPI docstring in sensors.py for both fields - Update tests: add SoC sensor fixtures with unique names, update assertions to use "unmet" key and check resolved_targets - Add "The schedule" section to scheduling.rst documenting both fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com>
Context: - Code review flagged potential ambiguity about margin sign in resolved_targets - Docs note lacked guidance on how to configure the state-of-charge sensor Change: - Add inline comments explaining that violations.empty guarantees margins >= 0 for both soc-minima and soc-maxima resolved branches - Expand the note in scheduling.rst to mention the flex model field syntax Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com>
Done in 02dfd42:
Added in 02dfd42 — a new "The schedule" section in |
Signed-off-by: F.N. Claessen <claessen@seita.nl>
_compute_unresolved_targets now always returns a list of datetime/violation (or margin) entries per constraint key, covering every violated or met slot by default, instead of a single dict for the first violation or tightest margin. Update the OpenAPI docstring prose and finished-job example on GET /api/v3_0/jobs/<uuid> accordingly, and regenerate openapi-specs.json to match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBuNdhpXdpHgq2ZDLUUq8
The "Accessing constraint results" section described each soc-minima/soc-maxima entry as a single datetime/violation (or margin) value for the tightest or first-violated slot. Update the field descriptions and JSON example to reflect that these are always lists, containing one entry per violated or met slot by default, to match the current jobs.py OpenAPI docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBuNdhpXdpHgq2ZDLUUq8
…constraints Context: - _compute_unresolved_targets now always wraps each soc-minima/soc-maxima entry under `unresolved`/`resolved` in a list (supporting the new `all` flag), instead of a single dict for the first/tightest slot. Change: - Update the four existing unresolved-target tests to index into the single-element list (`entry["soc-minima"][0][...]`) and assert the list has exactly one element, since each test only defines a single constraint datetime. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBuNdhpXdpHgq2ZDLUUq8
…targets Context: - _compute_unresolved_targets gained an `all` parameter (default True): report every violated/met slot, or (all=False) only the first violation / tightest margin. This behavior had no dedicated coverage yet. Change: - Add test_unresolved_targets_all_flag_soc_minima_violations: three soc-minima checkpoints are all unreachable given a capacity-limited battery; asserts the default (all=True) reports all three violated slots in chronological order, and that all=False (invoked directly via a spy on the private method) reports only the first. - Add test_unresolved_targets_all_flag_soc_minima_resolved_margins: two soc-minima checkpoints (one tight, one slack) are both met with different margins; asserts all=True reports both margins and all=False reports only the tightest one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBuNdhpXdpHgq2ZDLUUq8
Reports every violated/met slot per constraint by default, instead of only the first violation or tightest margin. Constraint entries are now always lists, even when all=False keeps just the single entry.
Avoids shadowing the all() builtin. Flips the polarity so the new, more informative multi-slot reporting is the implicit default.
Follows the production code rename in _compute_unresolved_targets.
…docs documentation/api/change_log.rst named the wrong field (result instead of scheduling_result), and SchedulingJobResult's docstring example still showed the pre-list-wrapping single-dict-per-constraint shape.
…, not meta Context: - Maintainer decided the soft SoC constraint analysis produced by StorageScheduler should be surfaced through the job's actual return value (job.return_value()) instead of a bolted-on `scheduling_result` field sourced from RQ job.meta. Change: - make_schedule() now returns the SchedulingJobResult dict when the scheduler produced one, or True otherwise (return type: bool | dict). This also fixes direct (non-RQ) make_schedule() calls, which previously silently dropped the constraint-analysis data. - GET /api/v3_0/jobs/<uuid> no longer reads job.meta[SCHEDULING_RESULT_KEY] or exposes a separate `scheduling_result` field; the data now arrives automatically via the existing `result` field. - Updated the OpenAPI docstring/schema/examples in jobs.py accordingly and regenerated flexmeasures/ui/static/openapi-specs.json. - Updated the SchedulingJobResult docstring to point at the `result` field instead of the now-removed `scheduling_result` field. Note: this is a breaking change for every finished StorageScheduler job, not only ones whose flex model defines soc-minima/soc-maxima — see the API changelog entry for details. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBuNdhpXdpHgq2ZDLUUq8
Context: - Follows the previous commit, which stopped exposing a separate `scheduling_result` field on GET /api/v3_0/jobs/<uuid> and instead surfaces soft SoC constraint analysis via the existing `result` field. Change: - Updated documentation/features/scheduling.rst's constraint-results section and worked example to reference `result` instead of `scheduling_result`, and to clarify that a finished StorageScheduler job always returns the analysis object (with empty arrays when no soc-minima/soc-maxima are defined), while other scheduling jobs keep returning `true`. - Marked the v3.0-32 API changelog entry as a breaking change: for every finished StorageScheduler job (not only ones with soc-minima/soc-maxima defined), `result` is now an object instead of the boolean `true` it used to return unconditionally. Flagged the risk for external integrators (e.g. flexmeasures-client, or custom scripts) that check `result === true`/`result is True` unconditionally. - Reworded the main changelog's PR #2072 one-liner so it no longer reads as purely additive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GLBuNdhpXdpHgq2ZDLUUq8
Context:
- GET /api/v3_0/jobs/<uuid> no longer exposes a separate
`scheduling_result` field; scheduling constraint analysis now arrives
via `result` directly (see the preceding scheduling.py/jobs.py commit).
Change:
- Fixed the stale comment on test_get_job_status_finished's assertion
and tightened it to assert the exact StorageScheduler result shape
({"unresolved": [], "resolved": []}), since this test's flex model
defines no soc-minima/soc-maxima.
- Added test_get_job_status_finished_with_unresolved_soc_minima,
driving an unreachable soc-minima (soft constraint via a breach
price) through the real trigger -> work_on_rq -> job-status flow, and
asserting `result` contains the expected unresolved entry and that
the old `scheduling_result` field is really gone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GLBuNdhpXdpHgq2ZDLUUq8
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Context:
- Maintainer wants result consistently a dict for every scheduling
job, not just StorageScheduler jobs with soft SoC constraints, to
simplify the docs and the UX of GET /api/v3_0/jobs/<uuid>.
Change:
- make_schedule() now always returns a dict: the SchedulingJobResult
dict when the scheduler produced one, otherwise an empty {} (for
now, pending a proper result spec for other schedulers).
- Adapted the one caller relying on the old truthy boolean
(cli/data_add.py's `add schedule` command): an empty dict is falsy,
so the "New schedule is stored." message no longer depends on the
return value's truthiness.
- Updated jobs.py's OpenAPI docstring/schema/example accordingly and
regenerated openapi-specs.json.
Follows the previous commit. Updated scheduling.rst's constraint- results section, the v3.0-32 API changelog breaking-change note, and the main changelog's PR #2072 one-liner to state that every finished scheduling job now returns an object for `result`, not only StorageScheduler jobs with soc-minima/soc-maxima defined.
- Fixed a stale comment in test_get_job_status_finished.
- Added a regression assertion to test_add_process (ProcessScheduler,
called directly via CLI, not via RQ) that "New schedule is stored."
still prints now that make_schedule() can return a falsy {} on
success.
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Python 3.10 resolves an older pint/numpy (0.24.4/2.2.6 vs 0.25.3/2.4.6 on 3.11+, per uv.lock), which can produce sub-1e-12 float noise after unit conversion. Exact equality then flakes on some runner architectures even though the values are correct to any sane precision. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
@nhoening I still need to deal with the 3 tests failing on Python 3.10 on a floating point imprecision, but nevertheless this is ready for review. |
nhoening
left a comment
There was a problem hiding this comment.
Pretty nice!
As usual, my comments mainly are about the docs & naming.
pytest.approx on the magnitude (added previously) doesn't cover the str(Quantity) comparisons in the same test, which still fail on Python 3.10's older pint (0.24.4) for cases like 914.7 EUR/kWh -> kEUR/MWh, where the converted magnitude carries ~1e-13 float noise that shows up in the string repr (e.g. "914.7000000000003" instead of "914.7"). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oSbjKoErHkpDmeBjkxinY
Per review, non-StorageScheduler jobs returned an empty {} result
where StorageScheduler jobs return {"unresolved": [...], "resolved":
[...]}. Default to the same two-key shape for all scheduling jobs, so
callers can rely on one consistent result structure regardless of
scheduler.
Also clarify the scheduling_result.py docstring's cross-reference to
the docs by naming the section explicitly, since "scheduling_constraint_results"
is a Sphinx anchor, not a string a reader would search the docs for.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oSbjKoErHkpDmeBjkxinY
- Simplify the API/main changelog entries for the scheduling result field per review suggestion, dropping the now-inaccurate note that non-StorageScheduler jobs return a different (empty-object) shape. - Restore the missing "Work on other schedulers" section header in scheduling.rst (lost in an earlier edit). - Fix a duplicated "Checking the status via the API" header; the second one covers the CLI command, not the API. - Add "if the battery is in an EV, charge en-route" to the shortfall use-case bullets. - Explicitly state that `violation`/`margin` are always positive magnitudes, never negative. - Fix a stale session-date placeholder in the architecture specialist's lessons-learned log. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oSbjKoErHkpDmeBjkxinY
The comment said make_schedule() returns an empty dict; it now
returns {"unresolved": [], "resolved": []} for every scheduler,
per the previous commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oSbjKoErHkpDmeBjkxinY
Per nhoening's review comment, the high-level "Inspecting schedules" section (errors, job status via API/CLI, asset status page, RQ dashboard) belongs before the more detailed "Accessing constraint results" section, not after it — readers should learn how to check on a job in general before diving into constraint-analysis specifics. Also cross-reference the constraint-results section from the API status-check paragraph, since that's precisely where the data lives. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015oSbjKoErHkpDmeBjkxinY
… updating the docs Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Signed-off-by: F.N. Claessen <claessen@seita.nl>
Description
Adds soft constraint analysis to scheduling job results: when a
StorageSchedulerjob finishes,GET /api/v3_0/jobs/<uuid>now reports whichsoc-minima/soc-maximatargets could not be met (unresolved) and which were met, together with their remaining headroom (resolved). This makes it possible to inspect why a schedule looks the way it does without having to reverse-engineer it from the raw power/SoC series.soc-minima/soc-maximaviolations and margins inStorageScheduler._compute_unresolved_targets(flexmeasures/data/models/planning/storage.py), keyed by asset ID.SchedulingJobResult(flexmeasures/data/services/scheduling_result.py) as the serializable container forunresolved/resolved, stored on the RQ job'sreturn_value(flexmeasures/data/services/scheduling.py).resultfield onGET /api/v3_0/jobs/<uuid>(flexmeasures/api/v3_0/jobs.py) — this is the only place constraint analysis is available;GET /api/v3_0/sensors/<id>/schedules/<uuid>continues to return power values only. The result field of theGET /api/v3_0/jobs/<uuid>endpoint is, for scheduling jobs, no longer a boolean, but an actual object where we can start returning more scheduling results. For now, it only containsunresolved/resolvedinfo.most_relevant_only: bool = Falseoption to_compute_unresolved_targets: by default every violated/met slot is reported (chronologically ordered); set toTrueto get only the single most relevant slot (first violation, or tightest margin) — either way, each constraint key holds a list.documentation/features/scheduling.rst("Accessing constraint results" section, with a worked multi-asset example), anddocumentation/api/change_log.rst(v3.0-32).documentation/changelog.rst.github/agents/*.mdspecialist instructions with lessons learned during this PR (see "Changes to AI infra" below).Response shape
{ "status": "FINISHED", "message": "Scheduling job finished.", "result": { "unresolved": [ { "asset": 42, "soc-minima": [ {"datetime": "2024-01-01T10:00:00+00:00", "violation": "260.0 kWh"}, {"datetime": "2024-01-01T10:15:00+00:00", "violation": "180.0 kWh"} ] } ], "resolved": [] } }unresolved/resolvedare lists of per-asset dicts, keyed by asset ID (not sensor ID) — so a multi-asset scheduling job reports constraint results for every flexible device it scheduled, in one response.soc-minima/soc-maximavalue is itself a list: one entry per violated slot (unresolved) or per met slot with its margin (resolved), chronologically ordered by default.soc-targets(hard constraints) are never reported here — the scheduler enforces them strictly, so a hard-constraint failure fails the job outright rather than appearing as a soft violation.Changes to AI infra
Part of this PR's development happened by handing work back and forth between GitHub Copilot's coding agent and Claude Code, both operating against the same repo-local specialist-agent framework (
AGENTS.md,.github/agents/*.md,.github/instructions/*.md). As a result, this PR also carries some infra changes unrelated to the feature itself but load-bearing for how the repo's AI agents operate:.github/agents/api-backward-compatibility-specialist.md,architecture-domain-specialist.md,documentation-developer-experience-specialist.md,test-specialist.md: appended "lessons learned" sections captured while building this feature (e.g. asset-keying/format-mismatch patterns, cross-document consistency checks, data-format-transformation test patterns)..github/instructions/feature-branch-sync.instructions.md(new): codifies checkinggit log --oneline origin/main...HEAD --left-right(not justgit status) before starting implementation work, and mergingorigin/mainin before proceeding — added after a session where the feature branch had drifted 10+ commits behindmain..github/instructions/docstrings.instructions.md: minor conventions (:returns:instead of:return:; line breaks only after punctuation).AGENTS.md: a session-lesson entry and a corresponding "branch in sync with main" pre-commit checklist item..claude/agents/*.md(new, 9 files): thin stubs mirroring the.github/agents/*.mdpersonas for Claude Code, added separately by the maintainer (not generated as part of the feature work).None of this affects runtime behavior; it's process/tooling scoped to how AI coding agents work in this repo.
Look & Feel
Request:
Response (finished scheduling job, one unresolved
soc-minimaviolated at two slots, one resolvedsoc-maximawith margin):{ "status": "FINISHED", "message": "Scheduling job has finished.", "result": null, "func_name": "flexmeasures.data.services.scheduling.create_schedule", "origin": "scheduling", "enqueued_at": "2026-04-28T10:00:00+00:00", "started_at": "2026-04-28T10:00:01+00:00", "ended_at": "2026-04-28T10:00:05+00:00", "exc_info": null, "result": { "unresolved": [ { "asset": 42, "soc-minima": [ {"datetime": "2024-01-01T10:00:00+00:00", "violation": "260.0 kWh"}, {"datetime": "2024-01-01T10:15:00+00:00", "violation": "180.0 kWh"} ] } ], "resolved": [ { "asset": 42, "soc-maxima": [ {"datetime": "2024-01-01T12:00:00+00:00", "margin": "40.0 kWh"} ] } ] } }If no SoC constraints were defined for any scheduled asset, both
unresolvedandresolvedare empty lists.How to test
Run the storage-scheduler test module, which covers unresolved/resolved reporting end to end (via
StorageScheduler.compute()) as well as themost_relevant_onlyflag directly:Key test functions:
test_unresolved_targets_soc_minima/test_unresolved_targets_soc_maxima— unresolved (violated) constraints are reported with the correct asset ID, datetime, and violation magnitude.test_unresolved_targets_none_when_met— met constraints are reported underresolvedwith a non-negative margin.test_unresolved_targets_no_soc_sensor— asset-keyed reporting still works for devices without astate-of-chargesensor in the flex model.test_unresolved_targets_most_relevant_only_flag_soc_minima_violations— default (most_relevant_only=False) reports every violated slot chronologically;most_relevant_only=Truereports only the first.test_unresolved_targets_most_relevant_only_flag_soc_minima_resolved_margins— same distinction forresolvedmargins (all met slots vs. only the tightest).End to end, also try triggering a real scheduling job with an unreachable
soc-minima/soc-maxima(e.g. viaPOST /api/v3_0/assets/<id>/schedules/trigger) and then inspectingGET /api/v3_0/jobs/<uuid>.Full suite (1346 passed, 4 xfailed at the time of writing) and
pre-commit run --all-filesshould both be clean.Further Improvements
most_relevant_onlyis currently only a parameter of the privateStorageScheduler._compute_unresolved_targetsmethod (always called with the default fromcompute()); it is not yet exposed through the public flex-model/flex-context or the API. A follow-up could expose it if callers want the terser single-slot-per-constraint response.Related Items
Closes/implements the "compute first unmet SoC targets" work tracked under this PR's branch name (
copilot/compute-first-unmet-targets).Sign-off