From 8dd3538e1d851d3e7582556dd6467921d98dff7e Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 16:15:16 +0100 Subject: [PATCH 01/11] Report how many beliefs a job computed next to how many it saved A saved count of 0 alone cannot tell a run that computed nothing from one that only repeated what is on record. save_generator_results now returns n_computed next to n_rows for each output, which reaches the result of reporting jobs and of plugin automation jobs, and make_schedule adds num-computed-beliefs next to num-beliefs, also on a dry run. The reporting job's log states both counts per output. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/jobs.py | 12 +++++++++-- flexmeasures/api/v3_0/tests/test_jobs_api.py | 9 ++++++++- .../tests/test_report_trigger_api_fresh_db.py | 4 +++- flexmeasures/cli/tests/test_automations.py | 13 ++++++++---- .../data/services/generator_results.py | 13 +++++++++--- flexmeasures/data/services/reporting.py | 11 +++++++--- flexmeasures/data/services/scheduling.py | 14 ++++++++----- .../data/services/scheduling_result.py | 11 ++++++++-- .../tests/test_generator_results_fresh_db.py | 20 ++++++++++++++++++- .../tests/test_plugin_automations_fresh_db.py | 6 +++--- flexmeasures/ui/static/openapi-specs.json | 7 ++++--- 11 files changed, 92 insertions(+), 28 deletions(-) diff --git a/flexmeasures/api/v3_0/jobs.py b/flexmeasures/api/v3_0/jobs.py index 524b33d745..efccb0783f 100644 --- a/flexmeasures/api/v3_0/jobs.py +++ b/flexmeasures/api/v3_0/jobs.py @@ -95,7 +95,11 @@ def get_job_status(self, job_id: str, **kwargs): ordered chronologically. Both arrays are empty when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used. The ``num-beliefs`` field holds - the total number of beliefs (scheduled values) saved to the database. + the total number of beliefs (scheduled values) saved to the database, + and ``num-computed-beliefs`` the total number the scheduler computed, + which also counts beliefs not saved because they were already on record. + For a finished reporting job, ``result`` is a list holding, per output, + the ``sensor_id``, the number of beliefs computed (``n_computed``) and the number of beliefs saved (``n_rows``). This is the only place constraint analysis is available — the sensor schedule endpoint (``GET /api/v3_0/sensors//schedules/``) returns power values only. @@ -142,7 +146,10 @@ def get_job_status(self, job_id: str, **kwargs): defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used). The ``num-beliefs`` field holds the total number of - beliefs (scheduled values) saved to the database. + beliefs (scheduled values) saved to the database, + and ``num-computed-beliefs`` the total number computed. + For a finished reporting job, this is a list holding, + per output, ``sensor_id``, ``n_computed`` and ``n_rows`` (the number of beliefs saved). nullable: true func-name: type: string @@ -224,6 +231,7 @@ def get_job_status(self, job_id: str, **kwargs): violation: "180.0 kWh" resolved: [] num-beliefs: 96 + num-computed-beliefs: 96 func-name: "flexmeasures.data.services.scheduling.create_schedule" origin: scheduling enqueued-at: "2026-04-28T10:00:00+00:00" diff --git a/flexmeasures/api/v3_0/tests/test_jobs_api.py b/flexmeasures/api/v3_0/tests/test_jobs_api.py index 38cfdf2486..8f8e9b5516 100644 --- a/flexmeasures/api/v3_0/tests/test_jobs_api.py +++ b/flexmeasures/api/v3_0/tests/test_jobs_api.py @@ -256,6 +256,7 @@ def test_get_job_status_finished( assert result["unresolved"] == [] assert result["resolved"] == [] assert result["num-beliefs"] == 96 + assert result["num-computed-beliefs"] == 96 assert data["exc-info"] is None assert_legacy_job_status_fields(data) @@ -315,9 +316,15 @@ def test_get_job_status_finished_with_unresolved_soc_minima( result = data["result"] assert isinstance(result, dict) - assert set(result.keys()) == {"unresolved", "resolved", "num-beliefs"} + assert set(result.keys()) == { + "unresolved", + "resolved", + "num-beliefs", + "num-computed-beliefs", + } assert isinstance(result["num-beliefs"], int) assert result["num-beliefs"] > 0 + assert result["num-computed-beliefs"] == result["num-beliefs"] asset_id = add_battery_assets["Test battery"].id unresolved_entry = next( diff --git a/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py b/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py index 917e30ec39..f603677829 100644 --- a/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py @@ -126,10 +126,12 @@ def test_trigger_report_worker_reports_nothing_saved_for_misaligned_inputs( job = app.queues["reporting"].fetch_job(response.json["job"]) assert job.get_status() == "finished" - # The count must exclude computed rows that persistence drops as NaN. + # The saved count must exclude computed rows that persistence drops as NaN, while the computed count includes them: + # the two inputs do not align on source, so adding them gives a NaN for each of their 2 x 10 rows. assert job.return_value() == [ { "sensor_id": hourly_output.id, + "n_computed": 20, "n_rows": 0, } ] diff --git a/flexmeasures/cli/tests/test_automations.py b/flexmeasures/cli/tests/test_automations.py index 8dd8172da3..391b4b0ca4 100644 --- a/flexmeasures/cli/tests/test_automations.py +++ b/flexmeasures/cli/tests/test_automations.py @@ -1992,8 +1992,8 @@ def test_run_report_automation_with_two_outputs_saves_both( assert (stored_sub.values.T == [0, 0, 0, 0, 0]).all() assert job.return_value() == [ - {"sensor_id": report_sensor_id, "n_rows": 5}, - {"sensor_id": report_sensor_2_id, "n_rows": 5}, + {"sensor_id": report_sensor_id, "n_computed": 5, "n_rows": 5}, + {"sensor_id": report_sensor_2_id, "n_computed": 5, "n_rows": 5}, ] import pandas as pd @@ -2160,7 +2160,7 @@ def compute(self, parameters=None): StubReporter(), {}, permitted_output_sensor_ids=None ) save.assert_called_once() - assert saved == [{"sensor_id": sensor1_id, "n_rows": 0}] + assert saved == [{"sensor_id": sensor1_id, "n_computed": 0, "n_rows": 0}] def test_report_job_persistence_failure_rolls_back_all_outputs( @@ -2267,12 +2267,17 @@ def test_a_rerun_report_job_counts_what_it_saved_and_says_why_it_saved_nothing( first = run_report_job(**job.kwargs) assert all(result["n_rows"] > 0 for result in first), first + assert all(result["n_computed"] == result["n_rows"] for result in first), first info = mocker.spy(app.logger, "info") warning = mocker.spy(app.logger, "warning") second = run_report_job(**job.kwargs) assert [result["n_rows"] for result in second] == [0] * len(first) + # The rerun computed as much as the first run did, which tells it apart from a run that computed nothing. + assert [result["n_computed"] for result in second] == [ + result["n_computed"] for result in first + ] assert any( "already on record" in call.args[0] for call in info.call_args_list ), info.call_args_list @@ -2319,7 +2324,7 @@ def compute(self, parameters=None): beliefs_before = count_beliefs() _, saved = compute_and_save_report(StubReporter(), {}) - assert saved == [{"sensor_id": sensor.id, "n_rows": 3}] + assert saved == [{"sensor_id": sensor.id, "n_computed": 3, "n_rows": 3}] assert count_beliefs() == beliefs_before + 3, "the results are staged" # A caller that decides against its transaction takes the report down with it. diff --git a/flexmeasures/data/services/generator_results.py b/flexmeasures/data/services/generator_results.py index 96065a6408..2baf58d5a2 100644 --- a/flexmeasures/data/services/generator_results.py +++ b/flexmeasures/data/services/generator_results.py @@ -136,8 +136,9 @@ def save_generator_results(results: list[dict]) -> list[dict]: A result may name events whose beliefs are saved even so, under "save_even_if_unchanged" (their event starts), as a report does where its belief has to supersede a belief of another source recorded since (see `AggregatorReporter`). - :returns: per result, the sensor id and the number of beliefs saved ("n_rows"), - which leaves out NaN values and beliefs that were already on record. + :returns: per result, the sensor id, the number of beliefs computed ("n_computed") and the number of beliefs saved ("n_rows"). + The computed count is every belief the generator returned, including NaN values and beliefs that were already on record. + The saved count leaves both of those out, so a rerun that only repeats what is on record saves 0 of the beliefs it computed. """ saved = [] with db.session.begin_nested(): @@ -153,7 +154,13 @@ def save_generator_results(results: list[dict]) -> list[dict]: data[is_forced], save_changed_beliefs_only=False ) n_saved = n_changed + n_forced - saved.append({"sensor_id": result["sensor"].id, "n_rows": n_saved}) + saved.append( + { + "sensor_id": result["sensor"].id, + "n_computed": len(data), + "n_rows": n_saved, + } + ) return saved diff --git a/flexmeasures/data/services/reporting.py b/flexmeasures/data/services/reporting.py index c8016ceaff..8be8f23792 100644 --- a/flexmeasures/data/services/reporting.py +++ b/flexmeasures/data/services/reporting.py @@ -130,7 +130,8 @@ def compute_and_save_report( and without committing, which is left to the caller. :returns: the computed results, and per result a summary of what was saved: - the sensor id and the number of beliefs saved, which leaves out NaN values and beliefs that were already on record. + the sensor id, the number of beliefs computed ("n_computed"), which counts every belief the report returned, + and the number of beliefs saved ("n_rows"), which leaves out NaN values and beliefs that were already on record. """ results = compute_report( reporter, parameters, permitted_output_sensor_ids, automation_id @@ -175,12 +176,16 @@ def run_report_job(data_source_id: int, parameters: dict) -> list[dict]: ) db.session.commit() + # Say both counts, as saving none of what was computed is not the same as computing nothing. summary = ", ".join( - f"{result['n_rows']} values on sensor {result['sensor_id']}" for result in saved + f"sensor {result['sensor_id']}: computed {result['n_computed']}, saved {result['n_rows']}" + for result in saved ) if any(result["n_rows"] for result in saved): current_app.logger.info( - "Report by %s ran successfully, producing %s.", source, summary + "Report by %s ran successfully, saving the values that were not yet on record (%s).", + source, + summary, ) elif any(count_persistable_values(result["data"]) for result in results): # Saving nothing is not the same as computing nothing: these values were all on record already. diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index ea372fe546..19131a05a8 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -971,10 +971,10 @@ def make_schedule( # noqa: C901 **scheduler_kwargs: dict, ) -> dict: """ - This function computes a schedule. It returns a dict, empty on schedulers - that don't (yet) produce further analysis. If the scheduler produced soft - state-of-charge constraint analysis (see ``SchedulingJobResult``), the dict - instead holds that analysis under ``unresolved`` and ``resolved`` keys. + This function computes a schedule. It returns a dict (see ``SchedulingJobResult``), + which counts the beliefs the schedule computed (``num-computed-beliefs``) and those it saved (``num-beliefs``). + If the scheduler produced soft state-of-charge constraint analysis, + the dict also holds that analysis under ``unresolved`` and ``resolved`` keys, which are empty lists otherwise. It can be queued as a job (see create_scheduling_job). In that case, it will probably run on a different FlexMeasures node than where the job is created. @@ -1100,6 +1100,7 @@ def make_schedule( # noqa: C901 ) scheduling_result_dict: dict = SchedulingJobResult().to_dict() to_save = [] + n_computed = 0 for result in consumption_schedule: if result.get("name") == SCHEDULING_RESULT_KEY: scheduling_result_dict = result["data"].to_dict() @@ -1142,6 +1143,7 @@ def make_schedule( # noqa: C901 # todo: move this into save_to_db bdf = bdf.resample_events(bdf.sensor.event_resolution) + n_computed += len(bdf) if not dry_run: to_save.append({"sensor": result["sensor"], "data": bdf}) else: @@ -1158,9 +1160,11 @@ def make_schedule( # noqa: C901 ) # Saved the way every data generator's results are: all or nothing, and counting the beliefs actually saved, - # which leaves out beliefs that repeat what is already on record. On a dry run nothing is saved, so the count is 0. + # which leaves out beliefs that repeat what is already on record. On a dry run nothing is saved, so that count is 0. + # The beliefs computed are counted as well, also on a dry run, so that saving none does not read as computing none. saved = save_generator_results(to_save) if to_save else [] scheduling_result_dict["num-beliefs"] = sum(entry["n_rows"] for entry in saved) + scheduling_result_dict["num-computed-beliefs"] = n_computed if not dry_run: scheduler.persist_flex_model() diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index 4448e0cc81..9916c9f17f 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -20,7 +20,10 @@ class SchedulingJobResult: - ``resolved``: List of soft constraints that were satisfied with available headroom - Each entry is a dict with ``"asset"`` field and constraint-type keys - Each constraint-type key holds a list of dicts, one per met slot (chronologically ordered): ``{"datetime": ISO 8601 UTC, "margin": "X kWh"}`` - - ``num-beliefs``: Total number of beliefs (scheduled values) saved to the database + - ``num-beliefs``: Total number of beliefs (scheduled values) saved to the database, + which leaves out beliefs that repeat what is already on record + - ``num-computed-beliefs``: Total number of beliefs (scheduled values) the scheduler computed, + including those that were not saved because they were already on record **Important:** ``soc-targets`` (hard constraints) are never included since they are strictly enforced by the scheduler. Only hard constraint failures cause job failure. @@ -45,7 +48,8 @@ class SchedulingJobResult: ], } ], - "num-beliefs": 96 + "num-beliefs": 96, + "num-computed-beliefs": 96 } For usage examples and interpretation guidance, see :ref:`scheduling_constraint_results`. @@ -54,6 +58,7 @@ class SchedulingJobResult: unresolved: list = field(default_factory=list) resolved: list = field(default_factory=list) num_beliefs: int = 0 + num_computed_beliefs: int = 0 def to_dict(self) -> dict: """Serialize to a JSON-compatible dict.""" @@ -61,6 +66,7 @@ def to_dict(self) -> dict: "unresolved": self.unresolved, "resolved": self.resolved, "num-beliefs": self.num_beliefs, + "num-computed-beliefs": self.num_computed_beliefs, } @classmethod @@ -70,4 +76,5 @@ def from_dict(cls, d: dict) -> "SchedulingJobResult": unresolved=d.get("unresolved", []), resolved=d.get("resolved", []), num_beliefs=d.get("num-beliefs", 0), + num_computed_beliefs=d.get("num-computed-beliefs", 0), ) diff --git a/flexmeasures/data/tests/test_generator_results_fresh_db.py b/flexmeasures/data/tests/test_generator_results_fresh_db.py index 5bd622d655..226c138c5a 100644 --- a/flexmeasures/data/tests/test_generator_results_fresh_db.py +++ b/flexmeasures/data/tests/test_generator_results_fresh_db.py @@ -158,12 +158,30 @@ def fail_on_second_save(data, **kwargs): def test_saving_results_counts_what_was_saved_and_repeats_nothing( fresh_db, two_outputs ): - """Each result says how many beliefs it saved, and saving the same results again saves none of them.""" + """Each result says how many beliefs it computed and how many it saved, and saving the same results again saves none of them. + + The computed count stays the same on the second save, which tells a rerun that repeats what is on record from one that computed nothing. + """ first = save_generator_results(two_outputs) fresh_db.session.commit() again = save_generator_results(two_outputs) fresh_db.session.commit() + assert [entry["n_computed"] for entry in first] == [3, 3] assert [entry["n_rows"] for entry in first] == [3, 3] + assert [entry["n_computed"] for entry in again] == [3, 3] assert [entry["n_rows"] for entry in again] == [0, 0] assert _beliefs_in_session(fresh_db) == 6 + + +def test_a_value_left_out_as_nan_counts_as_computed_but_not_as_saved( + fresh_db, two_outputs +): + """A NaN value the generator returned is counted as computed, but it is not saved, so it is not counted as saved.""" + data = two_outputs[0]["data"] + data.loc[data.index[0], "event_value"] = float("nan") + + saved = save_generator_results(two_outputs) + + assert [entry["n_computed"] for entry in saved] == [3, 3] + assert [entry["n_rows"] for entry in saved] == [2, 3] diff --git a/flexmeasures/data/tests/test_plugin_automations_fresh_db.py b/flexmeasures/data/tests/test_plugin_automations_fresh_db.py index 4b9463f56d..7a28630949 100644 --- a/flexmeasures/data/tests/test_plugin_automations_fresh_db.py +++ b/flexmeasures/data/tests/test_plugin_automations_fresh_db.py @@ -209,7 +209,7 @@ def test_plugin_job_keeps_its_result_by_its_queue( def test_plugin_job_reports_rows_saved( fresh_db, app, ingestion_plugin, ingestion_assets ): - """A rerun whose beliefs are unchanged reports that it saved nothing.""" + """A rerun whose beliefs are unchanged reports that it saved nothing, while it computed as much as the first run.""" root, sensors = ingestion_assets automation = make_automation(fresh_db, root, sensors[0], parameters={"value": 3.0}) n_rows = [] @@ -222,8 +222,8 @@ def test_plugin_job_reports_rows_saved( assert job.is_finished, job.exc_info n_rows.append(job.return_value()) assert n_rows == [ - [{"sensor_id": sensors[0].id, "n_rows": 1}], - [{"sensor_id": sensors[0].id, "n_rows": 0}], + [{"sensor_id": sensors[0].id, "n_computed": 1, "n_rows": 1}], + [{"sensor_id": sensors[0].id, "n_computed": 1, "n_rows": 0}], ] diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index dce443126e..70255df889 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5341,7 +5341,7 @@ "/api/v3_0/jobs/{uuid}": { "get": { "summary": "Get the status of a background job", - "description": "Look up a background job by its UUID and see whether it is\nqueued, running, finished, or failed.\n\nThe response includes a status message plus job metadata such\nas the queue name, function name, timestamps, and the job\nresult when available.\n\nFailed jobs also include traceback information when the worker\nstored it with the job result.\n\nFor a finished scheduling job, result is an object. For a\nStorageScheduler job it holds soft state-of-charge constraint\nanalysis: unresolved lists constraints the scheduler could not\nsatisfy, and resolved lists constraints that were satisfied\nwith some margin. Each device entry's soc-minima/soc-maxima\nvalue is a list, holding one entry per violated slot (for\nunresolved) or per met slot with its margin (for resolved),\nordered chronologically. Both arrays are empty when the flex model\ndefines no soc-minima/soc-maxima, or when a scheduler other\nthan StorageScheduler was used. The num-beliefs field holds\nthe total number of beliefs (scheduled values) saved to the database.\nThis is the only place constraint analysis is available \u2014 the sensor\nschedule endpoint (GET /api/v3_0/sensors//schedules/)\nreturns power values only.\n", + "description": "Look up a background job by its UUID and see whether it is\nqueued, running, finished, or failed.\n\nThe response includes a status message plus job metadata such\nas the queue name, function name, timestamps, and the job\nresult when available.\n\nFailed jobs also include traceback information when the worker\nstored it with the job result.\n\nFor a finished scheduling job, result is an object. For a\nStorageScheduler job it holds soft state-of-charge constraint\nanalysis: unresolved lists constraints the scheduler could not\nsatisfy, and resolved lists constraints that were satisfied\nwith some margin. Each device entry's soc-minima/soc-maxima\nvalue is a list, holding one entry per violated slot (for\nunresolved) or per met slot with its margin (for resolved),\nordered chronologically. Both arrays are empty when the flex model\ndefines no soc-minima/soc-maxima, or when a scheduler other\nthan StorageScheduler was used. The num-beliefs field holds\nthe total number of beliefs (scheduled values) saved to the database,\nand num-computed-beliefs the total number the scheduler computed,\nwhich also counts beliefs not saved because they were already on record.\nFor a finished reporting job, result is a list holding, per output,\nthe sensor_id, the number of beliefs computed (n_computed) and the number of beliefs saved (n_rows).\nThis is the only place constraint analysis is available \u2014 the sensor\nschedule endpoint (GET /api/v3_0/sensors//schedules/)\nreturns power values only.\n", "security": [ { "ApiKeyAuth": [] @@ -5386,7 +5386,7 @@ "description": "Human-readable description of the job status." }, "result": { - "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). The num-beliefs field holds the total number of beliefs (scheduled values) saved to the database.\n", + "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). The num-beliefs field holds the total number of beliefs (scheduled values) saved to the database, and num-computed-beliefs the total number computed. For a finished reporting job, this is a list holding, per output, sensor_id, n_computed and n_rows (the number of beliefs saved).\n", "nullable": true }, "func-name": { @@ -5491,7 +5491,8 @@ } ], "resolved": [], - "num-beliefs": 96 + "num-beliefs": 96, + "num-computed-beliefs": 96 }, "func-name": "flexmeasures.data.services.scheduling.create_schedule", "origin": "scheduling", From 1cdec492b6ef0dd72aa02423e0a103177997f443 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 16:15:40 +0100 Subject: [PATCH 02/11] Say what was computed and what was saved in add report and add schedule flexmeasures add report names the computed count where it saved fewer beliefs than it computed, or none, and flexmeasures add schedule says how many beliefs it saved, or that a rerun repeats what is on record, where it used to say "New schedule is stored." either way. A dry run of add schedule says how many beliefs it computed. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/cli/data_add.py | 36 +++++++++++++++---- .../cli/tests/test_data_add_fresh_db.py | 26 +++++++++++--- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index bfe53da281..75640c18f0 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -2199,16 +2199,31 @@ def add_schedule( # noqa C901 **MsgStyle.SUCCESS, ) else: - make_schedule( + scheduling_result = make_schedule( asset_or_sensor=get_asset_or_sensor_ref(asset_or_sensor), dry_run=dry_run, **scheduling_kwargs, ) - if not dry_run: - click.secho("New schedule is stored.", **MsgStyle.SUCCESS) + n_computed = scheduling_result["num-computed-beliefs"] + n_saved = scheduling_result["num-beliefs"] + if dry_run: + click.secho( + f"The schedule above was computed but not stored (because of --dry-run): computed {n_computed}, saved 0.", + **MsgStyle.SUCCESS, + ) + elif n_saved == n_computed: + click.secho( + f"New schedule is stored: saved {pluralize('belief', n_saved, include_count=True)}.", + **MsgStyle.SUCCESS, + ) + elif n_saved: + click.secho( + f"New schedule is stored: computed {n_computed}, saved {n_saved}, as the rest was already on record.", + **MsgStyle.SUCCESS, + ) else: click.secho( - "The schedule above was computed but not stored (because of --dry-run).", + f"The new schedule repeats beliefs already on record, so nothing new was stored (computed {n_computed}, saved 0).", **MsgStyle.SUCCESS, ) @@ -2504,19 +2519,26 @@ def add_report( # noqa: C901 # The report was saved above, unless running in dry mode, so say what that save stored. if not dry_run: n_saved = saved[i]["n_rows"] - if n_saved: + n_computed = saved[i]["n_computed"] + if n_saved and n_saved == n_computed: click.secho( f"Success. Saved {pluralize('belief', n_saved, include_count=True)} of the report for sensor `{sensor}` to the database.", **MsgStyle.SUCCESS, ) + elif n_saved: + click.secho( + f"Success. Saved {n_saved} of the {pluralize('belief', n_computed, include_count=True)} computed for the report for sensor `{sensor}` to the database:" + " the rest was already on record or had no value.", + **MsgStyle.SUCCESS, + ) elif count_persistable_values(data): click.secho( - f"The report for sensor `{sensor}` repeats beliefs already on record, so nothing new was saved.", + f"The report for sensor `{sensor}` repeats beliefs already on record, so nothing new was saved (computed {n_computed}, saved 0).", **MsgStyle.SUCCESS, ) else: click.secho( - f"Nothing was saved for sensor `{sensor}`, as the report holds no values to save." + f"Nothing was saved for sensor `{sensor}`, as the report holds no values to save (computed {n_computed}, saved 0)." " This can happen when its inputs do not align on source and belief time.", **MsgStyle.WARN, ) diff --git a/flexmeasures/cli/tests/test_data_add_fresh_db.py b/flexmeasures/cli/tests/test_data_add_fresh_db.py index a8e0759a16..aed579028a 100644 --- a/flexmeasures/cli/tests/test_data_add_fresh_db.py +++ b/flexmeasures/cli/tests/test_data_add_fresh_db.py @@ -1,6 +1,7 @@ import json import logging import os +import re from datetime import datetime import pandas as pd @@ -1303,7 +1304,10 @@ def test_add_report_says_when_a_rerun_saves_nothing_new( rerun = runner.invoke(add_report, cli_input) check_command_ran_without_error(rerun) - assert "repeats beliefs already on record, so nothing new was saved" in rerun.output + assert ( + "repeats beliefs already on record, so nothing new was saved (computed 5, saved 0)" + in rerun.output + ) assert "Success. Saved" not in rerun.output assert _count_beliefs(fresh_db, report_sensor_id) == recorded @@ -1490,7 +1494,7 @@ def test_add_process( # ProcessScheduler's make_schedule() call returns a dict (not the boolean # True), which used to be falsy enough to silently suppress this message; # confirm the message still appears. - assert "New schedule is stored." in result.output + assert "New schedule is stored" in result.output process_power_sensor = db.session.get(Sensor, process_power_sensor_id) schedule = process_power_sensor.search_beliefs() @@ -1528,15 +1532,27 @@ def test_add_schedule_dry_run_saves_no_beliefs( assert "Not saving schedule for sensor `power`" in result.output assert "covering events from" in result.output assert "computed but not stored (because of --dry-run)" in result.output - assert "New schedule is stored." not in result.output + assert "New schedule is stored" not in result.output sensor = fresh_db.session.get(Sensor, process_power_sensor) assert len(sensor.search_beliefs()) == 0 + # A dry run still says how much it computed, which a normal run then saves in full. + n_computed = int(re.search(r"computed (\d+), saved 0", result.output).group(1)) + assert n_computed > 0 # A normal run does record the schedule, so the dry run really skipped that step result = runner.invoke(add_schedule, cli_input) check_command_ran_without_error(result) - assert "New schedule is stored." in result.output - assert len(sensor.search_beliefs()) > 0 + assert f"New schedule is stored: saved {n_computed} beliefs." in result.output + assert len(sensor.search_beliefs()) == n_computed + + # Running it again computes the same schedule, which is already on record, so nothing new is saved. + result = runner.invoke(add_schedule, cli_input) + check_command_ran_without_error(result) + assert ( + f"repeats beliefs already on record, so nothing new was stored (computed {n_computed}, saved 0)" + in result.output + ) + assert len(sensor.search_beliefs()) == n_computed def test_add_schedule_rejects_dry_run_as_job(app, fresh_db, add_market_prices_fresh_db): From d4177b133ae38efe2e1a9debceb4dddc86c82df8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 16:22:41 +0100 Subject: [PATCH 03/11] Changelogs: a job's result counts the beliefs it computed and those it saved Extends the v1.1.0 entry for #2689 and #2571, as the meaning of these counts changes within that unreleased version, and adds lines to the API and CLI changelogs. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 1 + documentation/changelog.rst | 2 +- documentation/cli/change_log.rst | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 6329fd9a4a..e0d12860dc 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -44,6 +44,7 @@ v3.0-34 | November XX, 2026 - ``GET /api/v3_0/assets//jobs`` now accepts ``page`` and ``per-page`` to return jobs newest first, with ``num-records`` and ``filtered-records`` counts. Paginated results can be sorted by enqueue time or queue with ``sort-by`` and ``sort-dir``. Requests without ``page`` retain the full list and previous response shape. - ``GET /api/v3_0/assets//jobs`` also lists the jobs of the assets below the asset, at any depth, as far as the caller may read them; pass ``include-child-assets=false`` to list only the asset's own jobs. Its entries include ``created_via`` provenance, and the automation that created a job only where the caller may read that automation. +- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed next to how many it saved: a reporting job's ``result`` holds ``n_computed`` next to ``n_rows`` for each output, and a scheduling job's ``result`` holds ``num-computed-beliefs`` next to ``num-beliefs``. The saved counts leave out beliefs that were already on record (and, for a report, NaN values), so a rerun that only repeats what is on record saves 0 of the beliefs it computed. - ``GET /api/v3_0/assets//jobs`` renames its ``redis_connection_err`` field, which shipped in v1.0.0, to ``redis-connection-err``, as the automation endpoints spell it. It is read by FlexMeasures' own UI rather than by the FlexMeasures client. - ``GET /api/v3_0/assets`` now scopes a listing the way its parameters say. ``num-records`` respects the ``root`` and ``depth`` constraints, where it used to count every asset in the account and asset-type scope. And an explicit ``include_public=false`` is now honoured, also next to ``all_accessible`` or ``root``; leaving the parameter out keeps the behaviour it had, where ``all_accessible`` and ``root`` include public assets and a listing of a single account does not. - ``GET /api/v3_0/sensors//status`` now accepts an optional ``asset_id`` query parameter, naming the asset whose status page the sensor is reported on. It only affects the reported ``relation``, which used to claim that the sensor belongs to the asset shown, also for a sensor that is only listed because the asset's flex-context or graphs page refers to it. Reading the status of a sensor in the context of an asset requires read permission on that asset, too. diff --git a/documentation/changelog.rst b/documentation/changelog.rst index d7aa3b9e51..d687513f83 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -191,7 +191,7 @@ Bugfixes * An ``AggregatorReporter`` input that holds more than one belief about an event, such as one read with ``most-recent-beliefs-only: false``, is now refused with a message naming the sensor and the event, where it used to fail with a ``KeyError`` [see `PR #2741 `_] * A ``PandasReporter`` with ``droplevels`` no longer fails with ``BeliefDataframe has more than one row per event`` when several data sources reported the same input event, as happens for a while when a job reporting on a rolling window gets a new data source; it keeps one belief per event, unless an input sets ``one_deterministic_belief_per_event`` to ``false`` [see `PR #2595 `_] -* The result of a reporting or scheduling job now counts the beliefs it saved rather than those it computed, so that a report's ``n_rows`` and a schedule's ``num-beliefs`` read 0 when a rerun only repeats what is on record [see `PR #2689 `_ and `PR #2571 `_] +* The result of a reporting or scheduling job now counts both the beliefs it computed and those it saved, where it used to count only those computed, so that a report's ``n_rows`` and a schedule's ``num-beliefs`` read 0 when a rerun only repeats what is on record, while its ``n_computed`` and ``num-computed-beliefs`` still say how much it computed [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] * A forecaster registered by a plugin no longer needs to save its forecasts itself, but can return them instead: ``flexmeasures add forecasts`` now saves them, where it used to drop them while reporting them as created [see `PR #2689 `_ and `PR #2571 `_] * Running the same forecast twice from the same start no longer fails on the beliefs it already recorded [see `PR #2688 `_] * Whether a forecaster takes a regressor's belief as realized now follows the knowledge horizon of the regressor's sensor, as in timely-beliefs, so that the past readings of an instantaneous regressor (such as a thermometer read on the hour) and day-ahead prices (beliefs about future periods, which count as realized once the auction has cleared) are no longer kept from the model [see `PR #2692 `_] diff --git a/documentation/cli/change_log.rst b/documentation/cli/change_log.rst index 80b38ecab0..c1d1f74ef3 100644 --- a/documentation/cli/change_log.rst +++ b/documentation/cli/change_log.rst @@ -35,13 +35,14 @@ since v1.1.0 | November XX, 2026 * Add ``flexmeasures add report --as-job`` and the ``reporting`` worker queue for asynchronous one-off reports. * Add ``flexmeasures add forecasts --dry-run`` to compute a forecast and see what it would record, without saving anything to the database. It cannot be combined with ``--as-job``. * ``flexmeasures add report`` saves the reporter's config on its data source by default. As a report then records under a new source, anything referring to its old source by ID should be pointed at the new one after the first run. Pass ``--no-save-config`` to leave the config out and keep recording under the old source; ``--save-config`` is still accepted. -* ``flexmeasures add report`` saves all of a report's outputs or none of them, where each output used to be committed on its own, and says for each output how many beliefs it saved. +* ``flexmeasures add report`` saves all of a report's outputs or none of them, where each output used to be committed on its own, and says for each output how many beliefs it computed and how many of those it saved. * ``flexmeasures add forecasts`` saves the forecasts of a forecaster that a plugin registers, which used to be dropped while the command reported them as created. * ``flexmeasures add forecasts`` no longer fails when it runs again from the same ``--from-date``, and says how many of the forecast beliefs it computed were new and saved. * ``flexmeasures add forecasts`` no longer keeps every trained model as a file; pass ``--model-save-dir`` to keep them. **Scheduling** +* ``flexmeasures add schedule`` says how many beliefs it computed and how many of those it saved, so that a rerun which only repeats what is on record no longer reads as storing a new schedule. * Fix ``flexmeasures add schedule --dry-run`` saving a schedule when combined with ``--as-job``: the combination is now rejected, and a dry run reports the beliefs and events it would have saved. **Assets** From 19e0aa4b5cf15e5f59ac87384f92598ab03b5ca2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 16:37:20 +0100 Subject: [PATCH 04/11] Reflow the job status docs, and say what a report's NaN values mean for its log The GET /api/v3_0/jobs/ docstring now breaks lines only after punctuation, and so do the SchedulingJobResult bullets. A report that saved nothing new no longer logs that every value it computed was already on record, as the computed values can include NaN values, which are not on record. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/jobs.py | 54 ++++++++----------- flexmeasures/data/services/reporting.py | 4 +- .../data/services/scheduling_result.py | 4 +- flexmeasures/ui/static/openapi-specs.json | 2 +- 4 files changed, 27 insertions(+), 37 deletions(-) diff --git a/flexmeasures/api/v3_0/jobs.py b/flexmeasures/api/v3_0/jobs.py index efccb0783f..f661cff450 100644 --- a/flexmeasures/api/v3_0/jobs.py +++ b/flexmeasures/api/v3_0/jobs.py @@ -75,34 +75,28 @@ def get_job_status(self, job_id: str, **kwargs): get: summary: Get the status of a background job description: | - Look up a background job by its UUID and see whether it is - queued, running, finished, or failed. + Look up a background job by its UUID and see whether it is queued, running, finished, or failed. - The response includes a status message plus job metadata such - as the queue name, function name, timestamps, and the job - result when available. + The response includes a status message plus job metadata, + such as the queue name, function name, timestamps, and the job result when available. - Failed jobs also include traceback information when the worker - stored it with the job result. + Failed jobs also include traceback information when the worker stored it with the job result. - For a finished scheduling job, ``result`` is an object. For a - ``StorageScheduler`` job it holds soft state-of-charge constraint - analysis: ``unresolved`` lists constraints the scheduler could not - satisfy, and ``resolved`` lists constraints that were satisfied - with some margin. Each device entry's ``soc-minima``/``soc-maxima`` - value is a list, holding one entry per violated slot (for - ``unresolved``) or per met slot with its margin (for ``resolved``), - ordered chronologically. Both arrays are empty when the flex model - defines no ``soc-minima``/``soc-maxima``, or when a scheduler other - than ``StorageScheduler`` was used. The ``num-beliefs`` field holds - the total number of beliefs (scheduled values) saved to the database, + For a finished scheduling job, ``result`` is an object. + For a ``StorageScheduler`` job, it holds soft state-of-charge constraint analysis: + ``unresolved`` lists constraints the scheduler could not satisfy, + and ``resolved`` lists constraints that were satisfied with some margin. + Each device entry's ``soc-minima``/``soc-maxima`` value is a list, + holding one entry per violated slot (for ``unresolved``) or per met slot with its margin (for ``resolved``), ordered chronologically. + Both arrays are empty when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used. + This is the only place constraint analysis is available: + the sensor schedule endpoint (``GET /api/v3_0/sensors//schedules/``) returns power values only. + The ``num-beliefs`` field holds the total number of beliefs (scheduled values) saved to the database, and ``num-computed-beliefs`` the total number the scheduler computed, which also counts beliefs not saved because they were already on record. + For a finished reporting job, ``result`` is a list holding, per output, the ``sensor_id``, the number of beliefs computed (``n_computed``) and the number of beliefs saved (``n_rows``). - This is the only place constraint analysis is available — the sensor - schedule endpoint (``GET /api/v3_0/sensors//schedules/``) - returns power values only. security: - ApiKeyAuth: [] parameters: @@ -138,18 +132,14 @@ def get_job_status(self, job_id: str, **kwargs): description: Human-readable description of the job status. result: description: > - Return value of the job function, or null when not yet - available. For a finished scheduling job, this is an - object; a ``StorageScheduler`` job populates it with - ``unresolved``/``resolved`` soft state-of-charge - constraint analysis (empty arrays when the flex model - defines no ``soc-minima``/``soc-maxima``, or when a - scheduler other than ``StorageScheduler`` was used). - The ``num-beliefs`` field holds the total number of - beliefs (scheduled values) saved to the database, + Return value of the job function, or null when not yet available. + For a finished scheduling job, this is an object; + a ``StorageScheduler`` job populates it with ``unresolved``/``resolved`` soft state-of-charge constraint analysis + (empty arrays when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used). + The ``num-beliefs`` field holds the total number of beliefs (scheduled values) saved to the database, and ``num-computed-beliefs`` the total number computed. - For a finished reporting job, this is a list holding, - per output, ``sensor_id``, ``n_computed`` and ``n_rows`` (the number of beliefs saved). + For a finished reporting job, this is a list holding, per output, + ``sensor_id``, ``n_computed`` and ``n_rows`` (the number of beliefs saved). nullable: true func-name: type: string diff --git a/flexmeasures/data/services/reporting.py b/flexmeasures/data/services/reporting.py index 8be8f23792..a770d082ee 100644 --- a/flexmeasures/data/services/reporting.py +++ b/flexmeasures/data/services/reporting.py @@ -188,9 +188,9 @@ def run_report_job(data_source_id: int, parameters: dict) -> list[dict]: summary, ) elif any(count_persistable_values(result["data"]) for result in results): - # Saving nothing is not the same as computing nothing: these values were all on record already. + # Saving nothing is not the same as computing nothing: the values it computed were on record already, or NaN. current_app.logger.info( - "Report by %s ran successfully, but every value it computed was already on record (%s).", + "Report by %s ran successfully, but saved nothing new, as every value it computed was already on record or had no value (%s).", source, summary, ) diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index 9916c9f17f..aee3fa13c7 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -21,9 +21,9 @@ class SchedulingJobResult: - Each entry is a dict with ``"asset"`` field and constraint-type keys - Each constraint-type key holds a list of dicts, one per met slot (chronologically ordered): ``{"datetime": ISO 8601 UTC, "margin": "X kWh"}`` - ``num-beliefs``: Total number of beliefs (scheduled values) saved to the database, - which leaves out beliefs that repeat what is already on record + which leaves out beliefs that repeat what is already on record. - ``num-computed-beliefs``: Total number of beliefs (scheduled values) the scheduler computed, - including those that were not saved because they were already on record + including those that were not saved because they were already on record. **Important:** ``soc-targets`` (hard constraints) are never included since they are strictly enforced by the scheduler. Only hard constraint failures cause job failure. diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 70255df889..1014191925 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5341,7 +5341,7 @@ "/api/v3_0/jobs/{uuid}": { "get": { "summary": "Get the status of a background job", - "description": "Look up a background job by its UUID and see whether it is\nqueued, running, finished, or failed.\n\nThe response includes a status message plus job metadata such\nas the queue name, function name, timestamps, and the job\nresult when available.\n\nFailed jobs also include traceback information when the worker\nstored it with the job result.\n\nFor a finished scheduling job, result is an object. For a\nStorageScheduler job it holds soft state-of-charge constraint\nanalysis: unresolved lists constraints the scheduler could not\nsatisfy, and resolved lists constraints that were satisfied\nwith some margin. Each device entry's soc-minima/soc-maxima\nvalue is a list, holding one entry per violated slot (for\nunresolved) or per met slot with its margin (for resolved),\nordered chronologically. Both arrays are empty when the flex model\ndefines no soc-minima/soc-maxima, or when a scheduler other\nthan StorageScheduler was used. The num-beliefs field holds\nthe total number of beliefs (scheduled values) saved to the database,\nand num-computed-beliefs the total number the scheduler computed,\nwhich also counts beliefs not saved because they were already on record.\nFor a finished reporting job, result is a list holding, per output,\nthe sensor_id, the number of beliefs computed (n_computed) and the number of beliefs saved (n_rows).\nThis is the only place constraint analysis is available \u2014 the sensor\nschedule endpoint (GET /api/v3_0/sensors//schedules/)\nreturns power values only.\n", + "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe num-beliefs field holds the total number of beliefs (scheduled values) saved to the database,\nand num-computed-beliefs the total number the scheduler computed,\nwhich also counts beliefs not saved because they were already on record.\n\nFor a finished reporting job, result is a list holding, per output,\nthe sensor_id, the number of beliefs computed (n_computed) and the number of beliefs saved (n_rows).\n", "security": [ { "ApiKeyAuth": [] From 21864e7dd365a3228c74f1af13373ca3a85126eb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 16:37:34 +0100 Subject: [PATCH 05/11] Say so when the scheduler computed no beliefs in add schedule It used to print "New schedule is stored: saved 0 beliefs." in that case. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/cli/data_add.py | 5 ++++ .../cli/tests/test_data_add_fresh_db.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index 75640c18f0..1e37f69aa0 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -2211,6 +2211,11 @@ def add_schedule( # noqa C901 f"The schedule above was computed but not stored (because of --dry-run): computed {n_computed}, saved 0.", **MsgStyle.SUCCESS, ) + elif n_computed == 0: + click.secho( + "The scheduler computed no beliefs, so no new schedule was stored.", + **MsgStyle.WARN, + ) elif n_saved == n_computed: click.secho( f"New schedule is stored: saved {pluralize('belief', n_saved, include_count=True)}.", diff --git a/flexmeasures/cli/tests/test_data_add_fresh_db.py b/flexmeasures/cli/tests/test_data_add_fresh_db.py index aed579028a..25f67c7fd8 100644 --- a/flexmeasures/cli/tests/test_data_add_fresh_db.py +++ b/flexmeasures/cli/tests/test_data_add_fresh_db.py @@ -1555,6 +1555,29 @@ def test_add_schedule_dry_run_saves_no_beliefs( assert len(sensor.search_beliefs()) == n_computed +def test_add_schedule_says_when_the_scheduler_computed_nothing( + app, fresh_db, process_power_sensor, add_market_prices_fresh_db, monkeypatch +): + """A schedule of no beliefs is said to be one, rather than reported as a new schedule stored with 0 beliefs.""" + from flexmeasures.cli import data_add + from flexmeasures.data.services.scheduling_result import SchedulingJobResult + + monkeypatch.setattr( + data_add, "make_schedule", lambda **kwargs: SchedulingJobResult().to_dict() + ) + runner = app.test_cli_runner() + cli_input = to_flags(_process_schedule_cli_input(fresh_db, process_power_sensor)) + + result = runner.invoke(data_add.add_schedule, cli_input) + + check_command_ran_without_error(result) + assert ( + "The scheduler computed no beliefs, so no new schedule was stored." + in result.output + ) + assert "New schedule is stored" not in result.output + + def test_add_schedule_rejects_dry_run_as_job(app, fresh_db, add_market_prices_fresh_db): """A dry run cannot be queued: the worker would save the schedule that the flag rules out.""" from flexmeasures.cli.data_add import add_schedule, add_toy_account From 5de8ff4256e914e4f68e8263857899101855f315 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 17:08:42 +0100 Subject: [PATCH 06/11] Add num-saved-beliefs to a schedule's result, and keep num-beliefs as it was in v1.0 The deprecated num-beliefs counts the beliefs passed to the save again, as it did in v1.0, so that v1.1.0 does not change what it means: it equals num-computed-beliefs, except on a dry run, where nothing is passed to the save and it is 0. num-saved-beliefs now holds the count that num-beliefs briefly held since #2689: the beliefs actually saved. The changelogs no longer say that num-beliefs changed meaning, and the API changelog deprecates it. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 2 +- documentation/changelog.rst | 2 +- flexmeasures/api/v3_0/jobs.py | 16 +++++---- flexmeasures/api/v3_0/tests/test_jobs_api.py | 12 ++++--- flexmeasures/cli/data_add.py | 2 +- .../cli/tests/test_data_add_fresh_db.py | 36 +++++++++++++++++++ flexmeasures/data/services/scheduling.py | 9 +++-- .../data/services/scheduling_result.py | 21 +++++++---- flexmeasures/ui/static/openapi-specs.json | 9 ++--- 9 files changed, 83 insertions(+), 26 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index e0d12860dc..67e8ec69fe 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -44,7 +44,7 @@ v3.0-34 | November XX, 2026 - ``GET /api/v3_0/assets//jobs`` now accepts ``page`` and ``per-page`` to return jobs newest first, with ``num-records`` and ``filtered-records`` counts. Paginated results can be sorted by enqueue time or queue with ``sort-by`` and ``sort-dir``. Requests without ``page`` retain the full list and previous response shape. - ``GET /api/v3_0/assets//jobs`` also lists the jobs of the assets below the asset, at any depth, as far as the caller may read them; pass ``include-child-assets=false`` to list only the asset's own jobs. Its entries include ``created_via`` provenance, and the automation that created a job only where the caller may read that automation. -- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed next to how many it saved: a reporting job's ``result`` holds ``n_computed`` next to ``n_rows`` for each output, and a scheduling job's ``result`` holds ``num-computed-beliefs`` next to ``num-beliefs``. The saved counts leave out beliefs that were already on record (and, for a report, NaN values), so a rerun that only repeats what is on record saves 0 of the beliefs it computed. +- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed next to how many it saved. A reporting job's ``result`` holds ``n_computed`` next to ``n_rows`` (saved) for each output. A scheduling job's ``result`` holds ``num-computed-beliefs`` and ``num-saved-beliefs``. The saved counts leave out beliefs that were already on record (and, for a report, NaN values), so a rerun that only repeats what is on record saves 0 of the beliefs it computed. A scheduling job's ``num-beliefs`` is deprecated in favour of these two; it keeps counting the beliefs passed to the save, which equals ``num-computed-beliefs``. - ``GET /api/v3_0/assets//jobs`` renames its ``redis_connection_err`` field, which shipped in v1.0.0, to ``redis-connection-err``, as the automation endpoints spell it. It is read by FlexMeasures' own UI rather than by the FlexMeasures client. - ``GET /api/v3_0/assets`` now scopes a listing the way its parameters say. ``num-records`` respects the ``root`` and ``depth`` constraints, where it used to count every asset in the account and asset-type scope. And an explicit ``include_public=false`` is now honoured, also next to ``all_accessible`` or ``root``; leaving the parameter out keeps the behaviour it had, where ``all_accessible`` and ``root`` include public assets and a listing of a single account does not. - ``GET /api/v3_0/sensors//status`` now accepts an optional ``asset_id`` query parameter, naming the asset whose status page the sensor is reported on. It only affects the reported ``relation``, which used to claim that the sensor belongs to the asset shown, also for a sensor that is only listed because the asset's flex-context or graphs page refers to it. Reading the status of a sensor in the context of an asset requires read permission on that asset, too. diff --git a/documentation/changelog.rst b/documentation/changelog.rst index d687513f83..7a1d8c42e8 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -191,7 +191,7 @@ Bugfixes * An ``AggregatorReporter`` input that holds more than one belief about an event, such as one read with ``most-recent-beliefs-only: false``, is now refused with a message naming the sensor and the event, where it used to fail with a ``KeyError`` [see `PR #2741 `_] * A ``PandasReporter`` with ``droplevels`` no longer fails with ``BeliefDataframe has more than one row per event`` when several data sources reported the same input event, as happens for a while when a job reporting on a rolling window gets a new data source; it keeps one belief per event, unless an input sets ``one_deterministic_belief_per_event`` to ``false`` [see `PR #2595 `_] -* The result of a reporting or scheduling job now counts both the beliefs it computed and those it saved, where it used to count only those computed, so that a report's ``n_rows`` and a schedule's ``num-beliefs`` read 0 when a rerun only repeats what is on record, while its ``n_computed`` and ``num-computed-beliefs`` still say how much it computed [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] +* The result of a reporting job counts, per output, both the beliefs it computed (``n_computed``) and those it saved (``n_rows``), and the result of a scheduling job adds ``num-computed-beliefs`` and ``num-saved-beliefs``, so that a rerun which only repeats what is on record reads as having saved nothing, rather than as having computed nothing; a schedule's ``num-beliefs`` keeps its meaning (the beliefs passed to the save), but is deprecated [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] * A forecaster registered by a plugin no longer needs to save its forecasts itself, but can return them instead: ``flexmeasures add forecasts`` now saves them, where it used to drop them while reporting them as created [see `PR #2689 `_ and `PR #2571 `_] * Running the same forecast twice from the same start no longer fails on the beliefs it already recorded [see `PR #2688 `_] * Whether a forecaster takes a regressor's belief as realized now follows the knowledge horizon of the regressor's sensor, as in timely-beliefs, so that the past readings of an instantaneous regressor (such as a thermometer read on the hour) and day-ahead prices (beliefs about future periods, which count as realized once the auction has cleared) are no longer kept from the model [see `PR #2692 `_] diff --git a/flexmeasures/api/v3_0/jobs.py b/flexmeasures/api/v3_0/jobs.py index f661cff450..9856d70708 100644 --- a/flexmeasures/api/v3_0/jobs.py +++ b/flexmeasures/api/v3_0/jobs.py @@ -91,9 +91,11 @@ def get_job_status(self, job_id: str, **kwargs): Both arrays are empty when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used. This is the only place constraint analysis is available: the sensor schedule endpoint (``GET /api/v3_0/sensors//schedules/``) returns power values only. - The ``num-beliefs`` field holds the total number of beliefs (scheduled values) saved to the database, - and ``num-computed-beliefs`` the total number the scheduler computed, - which also counts beliefs not saved because they were already on record. + The ``num-computed-beliefs`` field holds the total number of beliefs (scheduled values) the scheduler computed, + and ``num-saved-beliefs`` the total number saved to the database, + which leaves out beliefs not saved because they were already on record. + The ``num-beliefs`` field is deprecated in favour of these two: + it holds the number of beliefs passed to the save, as it did before, which equals ``num-computed-beliefs``. For a finished reporting job, ``result`` is a list holding, per output, the ``sensor_id``, the number of beliefs computed (``n_computed``) and the number of beliefs saved (``n_rows``). @@ -136,8 +138,9 @@ def get_job_status(self, job_id: str, **kwargs): For a finished scheduling job, this is an object; a ``StorageScheduler`` job populates it with ``unresolved``/``resolved`` soft state-of-charge constraint analysis (empty arrays when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used). - The ``num-beliefs`` field holds the total number of beliefs (scheduled values) saved to the database, - and ``num-computed-beliefs`` the total number computed. + The ``num-computed-beliefs`` field holds the total number of beliefs (scheduled values) computed, + and ``num-saved-beliefs`` the total number saved to the database. + The ``num-beliefs`` field is deprecated: it equals ``num-computed-beliefs``. For a finished reporting job, this is a list holding, per output, ``sensor_id``, ``n_computed`` and ``n_rows`` (the number of beliefs saved). nullable: true @@ -220,8 +223,9 @@ def get_job_status(self, job_id: str, **kwargs): - datetime: "2024-01-01T10:15:00+00:00" violation: "180.0 kWh" resolved: [] - num-beliefs: 96 num-computed-beliefs: 96 + num-saved-beliefs: 96 + num-beliefs: 96 func-name: "flexmeasures.data.services.scheduling.create_schedule" origin: scheduling enqueued-at: "2026-04-28T10:00:00+00:00" diff --git a/flexmeasures/api/v3_0/tests/test_jobs_api.py b/flexmeasures/api/v3_0/tests/test_jobs_api.py index 8f8e9b5516..2890607211 100644 --- a/flexmeasures/api/v3_0/tests/test_jobs_api.py +++ b/flexmeasures/api/v3_0/tests/test_jobs_api.py @@ -255,8 +255,10 @@ def test_get_job_status_finished( result = data["result"] assert result["unresolved"] == [] assert result["resolved"] == [] - assert result["num-beliefs"] == 96 assert result["num-computed-beliefs"] == 96 + assert result["num-saved-beliefs"] == 96 + # The deprecated num-beliefs keeps counting the beliefs passed to the save. + assert result["num-beliefs"] == 96 assert data["exc-info"] is None assert_legacy_job_status_fields(data) @@ -321,10 +323,12 @@ def test_get_job_status_finished_with_unresolved_soc_minima( "resolved", "num-beliefs", "num-computed-beliefs", + "num-saved-beliefs", } - assert isinstance(result["num-beliefs"], int) - assert result["num-beliefs"] > 0 - assert result["num-computed-beliefs"] == result["num-beliefs"] + assert isinstance(result["num-saved-beliefs"], int) + assert result["num-saved-beliefs"] > 0 + assert result["num-computed-beliefs"] == result["num-saved-beliefs"] + assert result["num-beliefs"] == result["num-computed-beliefs"] asset_id = add_battery_assets["Test battery"].id unresolved_entry = next( diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index 1e37f69aa0..0bfd50b9d4 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -2205,7 +2205,7 @@ def add_schedule( # noqa C901 **scheduling_kwargs, ) n_computed = scheduling_result["num-computed-beliefs"] - n_saved = scheduling_result["num-beliefs"] + n_saved = scheduling_result["num-saved-beliefs"] if dry_run: click.secho( f"The schedule above was computed but not stored (because of --dry-run): computed {n_computed}, saved 0.", diff --git a/flexmeasures/cli/tests/test_data_add_fresh_db.py b/flexmeasures/cli/tests/test_data_add_fresh_db.py index 25f67c7fd8..320a1034c0 100644 --- a/flexmeasures/cli/tests/test_data_add_fresh_db.py +++ b/flexmeasures/cli/tests/test_data_add_fresh_db.py @@ -1555,6 +1555,42 @@ def test_add_schedule_dry_run_saves_no_beliefs( assert len(sensor.search_beliefs()) == n_computed +def test_schedule_results_count_computed_and_saved_beliefs_and_keep_num_beliefs( + app, fresh_db, process_power_sensor, add_market_prices_fresh_db, monkeypatch +): + """A schedule's result counts the beliefs computed and those saved, and the deprecated num-beliefs keeps its v1.0 meaning. + + That meaning is the number of beliefs passed to the save: none on a dry run, and all computed beliefs otherwise, + also on a rerun that saves none of them, because they are already on record. + """ + from flexmeasures.cli import data_add + + results = [] + real_make_schedule = data_add.make_schedule + + def make_schedule_and_keep_result(**kwargs): + result = real_make_schedule(**kwargs) + results.append(result) + return result + + monkeypatch.setattr(data_add, "make_schedule", make_schedule_and_keep_result) + runner = app.test_cli_runner() + cli_input = to_flags(_process_schedule_cli_input(fresh_db, process_power_sensor)) + + for flags in (["--dry-run"], [], []): + check_command_ran_without_error( + runner.invoke(data_add.add_schedule, cli_input + flags) + ) + + n = results[0]["num-computed-beliefs"] + assert n > 0 + counts = [ + (r["num-computed-beliefs"], r["num-saved-beliefs"], r["num-beliefs"]) + for r in results + ] + assert counts == [(n, 0, 0), (n, n, n), (n, 0, n)] + + def test_add_schedule_says_when_the_scheduler_computed_nothing( app, fresh_db, process_power_sensor, add_market_prices_fresh_db, monkeypatch ): diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 19131a05a8..4426b597fb 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -972,7 +972,8 @@ def make_schedule( # noqa: C901 ) -> dict: """ This function computes a schedule. It returns a dict (see ``SchedulingJobResult``), - which counts the beliefs the schedule computed (``num-computed-beliefs``) and those it saved (``num-beliefs``). + which counts the beliefs the schedule computed (``num-computed-beliefs``) and those it saved (``num-saved-beliefs``). + It also holds the deprecated ``num-beliefs``, which counts the beliefs passed to the save, as it did in v1.0. If the scheduler produced soft state-of-charge constraint analysis, the dict also holds that analysis under ``unresolved`` and ``resolved`` keys, which are empty lists otherwise. @@ -1163,8 +1164,12 @@ def make_schedule( # noqa: C901 # which leaves out beliefs that repeat what is already on record. On a dry run nothing is saved, so that count is 0. # The beliefs computed are counted as well, also on a dry run, so that saving none does not read as computing none. saved = save_generator_results(to_save) if to_save else [] - scheduling_result_dict["num-beliefs"] = sum(entry["n_rows"] for entry in saved) scheduling_result_dict["num-computed-beliefs"] = n_computed + scheduling_result_dict["num-saved-beliefs"] = sum( + entry["n_rows"] for entry in saved + ) + # The deprecated num-beliefs keeps its v1.0 meaning: the beliefs passed to the save, which are none on a dry run. + scheduling_result_dict["num-beliefs"] = sum(entry["n_computed"] for entry in saved) if not dry_run: scheduler.persist_flex_model() diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index aee3fa13c7..c569d3a0fd 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -20,10 +20,12 @@ class SchedulingJobResult: - ``resolved``: List of soft constraints that were satisfied with available headroom - Each entry is a dict with ``"asset"`` field and constraint-type keys - Each constraint-type key holds a list of dicts, one per met slot (chronologically ordered): ``{"datetime": ISO 8601 UTC, "margin": "X kWh"}`` - - ``num-beliefs``: Total number of beliefs (scheduled values) saved to the database, - which leaves out beliefs that repeat what is already on record. - ``num-computed-beliefs``: Total number of beliefs (scheduled values) the scheduler computed, including those that were not saved because they were already on record. + - ``num-saved-beliefs``: Total number of beliefs (scheduled values) saved to the database, + which leaves out beliefs that repeat what is already on record. + - ``num-beliefs``: Deprecated, kept with its v1.0 meaning: + the total number of beliefs passed to the save, which equals ``num-computed-beliefs``, except on a dry run, where it is 0. **Important:** ``soc-targets`` (hard constraints) are never included since they are strictly enforced by the scheduler. Only hard constraint failures cause job failure. @@ -48,8 +50,9 @@ class SchedulingJobResult: ], } ], - "num-beliefs": 96, - "num-computed-beliefs": 96 + "num-computed-beliefs": 96, + "num-saved-beliefs": 96, + "num-beliefs": 96 } For usage examples and interpretation guidance, see :ref:`scheduling_constraint_results`. @@ -57,16 +60,19 @@ class SchedulingJobResult: unresolved: list = field(default_factory=list) resolved: list = field(default_factory=list) - num_beliefs: int = 0 num_computed_beliefs: int = 0 + num_saved_beliefs: int = 0 + #: Deprecated, see ``num-beliefs`` above. + num_beliefs: int = 0 def to_dict(self) -> dict: """Serialize to a JSON-compatible dict.""" return { "unresolved": self.unresolved, "resolved": self.resolved, - "num-beliefs": self.num_beliefs, "num-computed-beliefs": self.num_computed_beliefs, + "num-saved-beliefs": self.num_saved_beliefs, + "num-beliefs": self.num_beliefs, } @classmethod @@ -75,6 +81,7 @@ def from_dict(cls, d: dict) -> "SchedulingJobResult": return cls( unresolved=d.get("unresolved", []), resolved=d.get("resolved", []), - num_beliefs=d.get("num-beliefs", 0), num_computed_beliefs=d.get("num-computed-beliefs", 0), + num_saved_beliefs=d.get("num-saved-beliefs", 0), + num_beliefs=d.get("num-beliefs", 0), ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 1014191925..0716f3cd7b 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5341,7 +5341,7 @@ "/api/v3_0/jobs/{uuid}": { "get": { "summary": "Get the status of a background job", - "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe num-beliefs field holds the total number of beliefs (scheduled values) saved to the database,\nand num-computed-beliefs the total number the scheduler computed,\nwhich also counts beliefs not saved because they were already on record.\n\nFor a finished reporting job, result is a list holding, per output,\nthe sensor_id, the number of beliefs computed (n_computed) and the number of beliefs saved (n_rows).\n", + "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe num-computed-beliefs field holds the total number of beliefs (scheduled values) the scheduler computed,\nand num-saved-beliefs the total number saved to the database,\nwhich leaves out beliefs not saved because they were already on record.\nThe num-beliefs field is deprecated in favour of these two:\nit holds the number of beliefs passed to the save, as it did before, which equals num-computed-beliefs.\n\nFor a finished reporting job, result is a list holding, per output,\nthe sensor_id, the number of beliefs computed (n_computed) and the number of beliefs saved (n_rows).\n", "security": [ { "ApiKeyAuth": [] @@ -5386,7 +5386,7 @@ "description": "Human-readable description of the job status." }, "result": { - "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). The num-beliefs field holds the total number of beliefs (scheduled values) saved to the database, and num-computed-beliefs the total number computed. For a finished reporting job, this is a list holding, per output, sensor_id, n_computed and n_rows (the number of beliefs saved).\n", + "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). The num-computed-beliefs field holds the total number of beliefs (scheduled values) computed, and num-saved-beliefs the total number saved to the database. The num-beliefs field is deprecated: it equals num-computed-beliefs. For a finished reporting job, this is a list holding, per output, sensor_id, n_computed and n_rows (the number of beliefs saved).\n", "nullable": true }, "func-name": { @@ -5491,8 +5491,9 @@ } ], "resolved": [], - "num-beliefs": 96, - "num-computed-beliefs": 96 + "num-computed-beliefs": 96, + "num-saved-beliefs": 96, + "num-beliefs": 96 }, "func-name": "flexmeasures.data.services.scheduling.create_schedule", "origin": "scheduling", From ee6173c62665370df4d0ebc8ce58fd797d7f370f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 17:33:19 +0100 Subject: [PATCH 07/11] List each output's computed and saved beliefs in reporting and scheduling job results A reporting job's result is now {"outputs": [...]}, with, per output sensor, the sensor ID and its num-computed-beliefs and num-saved-beliefs. A plugin automation job's result, which comes from the same save, takes the same shape. A scheduling job's result gains the same outputs list, counted per sensor in make_schedule, with saved 0 on a dry run, and drops the top-level num-computed-beliefs and num-saved-beliefs, which were never released. Its num-beliefs is no longer called deprecated, and keeps its v1.0 meaning: the beliefs passed to the save. save_generator_results keeps its internal snake_case return, which outputs_of_job_result turns into this shape. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 2 +- documentation/changelog.rst | 2 +- flexmeasures/api/v3_0/jobs.py | 27 ++++---- flexmeasures/api/v3_0/tests/test_jobs_api.py | 19 ++--- .../tests/test_report_trigger_api_fresh_db.py | 32 +++++++-- flexmeasures/cli/data_add.py | 8 ++- flexmeasures/cli/tests/test_automations.py | 38 +++++++--- .../cli/tests/test_data_add_fresh_db.py | 36 ---------- flexmeasures/data/automations.py | 12 +++- .../data/services/generator_results.py | 16 +++++ flexmeasures/data/services/reporting.py | 8 ++- flexmeasures/data/services/scheduling.py | 23 ++++--- .../data/services/scheduling_result.py | 28 ++++---- .../tests/test_plugin_automations_fresh_db.py | 20 +++++- .../data/tests/test_scheduling_jobs.py | 69 +++++++++++++++++++ flexmeasures/ui/static/openapi-specs.json | 13 ++-- 16 files changed, 234 insertions(+), 119 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 67e8ec69fe..8d081f247c 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -44,7 +44,7 @@ v3.0-34 | November XX, 2026 - ``GET /api/v3_0/assets//jobs`` now accepts ``page`` and ``per-page`` to return jobs newest first, with ``num-records`` and ``filtered-records`` counts. Paginated results can be sorted by enqueue time or queue with ``sort-by`` and ``sort-dir``. Requests without ``page`` retain the full list and previous response shape. - ``GET /api/v3_0/assets//jobs`` also lists the jobs of the assets below the asset, at any depth, as far as the caller may read them; pass ``include-child-assets=false`` to list only the asset's own jobs. Its entries include ``created_via`` provenance, and the automation that created a job only where the caller may read that automation. -- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed next to how many it saved. A reporting job's ``result`` holds ``n_computed`` next to ``n_rows`` (saved) for each output. A scheduling job's ``result`` holds ``num-computed-beliefs`` and ``num-saved-beliefs``. The saved counts leave out beliefs that were already on record (and, for a report, NaN values), so a rerun that only repeats what is on record saves 0 of the beliefs it computed. A scheduling job's ``num-beliefs`` is deprecated in favour of these two; it keeps counting the beliefs passed to the save, which equals ``num-computed-beliefs``. +- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed and how many it saved, per output sensor. A reporting job's ``result`` is an object holding an ``outputs`` list, and a scheduling job's ``result`` gains the same ``outputs`` list, next to its unchanged ``num-beliefs``. Each entry holds the ``sensor`` (its ID), ``num-computed-beliefs`` and ``num-saved-beliefs``. The saved count leaves out beliefs that were already on record (and NaN values), so a rerun that only repeats what is on record saves 0 of the beliefs it computed. - ``GET /api/v3_0/assets//jobs`` renames its ``redis_connection_err`` field, which shipped in v1.0.0, to ``redis-connection-err``, as the automation endpoints spell it. It is read by FlexMeasures' own UI rather than by the FlexMeasures client. - ``GET /api/v3_0/assets`` now scopes a listing the way its parameters say. ``num-records`` respects the ``root`` and ``depth`` constraints, where it used to count every asset in the account and asset-type scope. And an explicit ``include_public=false`` is now honoured, also next to ``all_accessible`` or ``root``; leaving the parameter out keeps the behaviour it had, where ``all_accessible`` and ``root`` include public assets and a listing of a single account does not. - ``GET /api/v3_0/sensors//status`` now accepts an optional ``asset_id`` query parameter, naming the asset whose status page the sensor is reported on. It only affects the reported ``relation``, which used to claim that the sensor belongs to the asset shown, also for a sensor that is only listed because the asset's flex-context or graphs page refers to it. Reading the status of a sensor in the context of an asset requires read permission on that asset, too. diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 7a1d8c42e8..042a79db76 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -191,7 +191,7 @@ Bugfixes * An ``AggregatorReporter`` input that holds more than one belief about an event, such as one read with ``most-recent-beliefs-only: false``, is now refused with a message naming the sensor and the event, where it used to fail with a ``KeyError`` [see `PR #2741 `_] * A ``PandasReporter`` with ``droplevels`` no longer fails with ``BeliefDataframe has more than one row per event`` when several data sources reported the same input event, as happens for a while when a job reporting on a rolling window gets a new data source; it keeps one belief per event, unless an input sets ``one_deterministic_belief_per_event`` to ``false`` [see `PR #2595 `_] -* The result of a reporting job counts, per output, both the beliefs it computed (``n_computed``) and those it saved (``n_rows``), and the result of a scheduling job adds ``num-computed-beliefs`` and ``num-saved-beliefs``, so that a rerun which only repeats what is on record reads as having saved nothing, rather than as having computed nothing; a schedule's ``num-beliefs`` keeps its meaning (the beliefs passed to the save), but is deprecated [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] +* The results of reporting and scheduling jobs now list, per output sensor, the beliefs computed and those saved, so that a rerun which only repeats what is on record reads as having saved nothing, rather than as having computed nothing; a schedule's ``num-beliefs`` is unchanged [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] * A forecaster registered by a plugin no longer needs to save its forecasts itself, but can return them instead: ``flexmeasures add forecasts`` now saves them, where it used to drop them while reporting them as created [see `PR #2689 `_ and `PR #2571 `_] * Running the same forecast twice from the same start no longer fails on the beliefs it already recorded [see `PR #2688 `_] * Whether a forecaster takes a regressor's belief as realized now follows the knowledge horizon of the regressor's sensor, as in timely-beliefs, so that the past readings of an instantaneous regressor (such as a thermometer read on the hour) and day-ahead prices (beliefs about future periods, which count as realized once the auction has cleared) are no longer kept from the model [see `PR #2692 `_] diff --git a/flexmeasures/api/v3_0/jobs.py b/flexmeasures/api/v3_0/jobs.py index 9856d70708..c740116039 100644 --- a/flexmeasures/api/v3_0/jobs.py +++ b/flexmeasures/api/v3_0/jobs.py @@ -91,14 +91,12 @@ def get_job_status(self, job_id: str, **kwargs): Both arrays are empty when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used. This is the only place constraint analysis is available: the sensor schedule endpoint (``GET /api/v3_0/sensors//schedules/``) returns power values only. - The ``num-computed-beliefs`` field holds the total number of beliefs (scheduled values) the scheduler computed, - and ``num-saved-beliefs`` the total number saved to the database, - which leaves out beliefs not saved because they were already on record. - The ``num-beliefs`` field is deprecated in favour of these two: - it holds the number of beliefs passed to the save, as it did before, which equals ``num-computed-beliefs``. + The ``outputs`` field lists one entry per sensor the schedule records on: + the ``sensor`` (its ID), the number of beliefs (scheduled values) the scheduler computed for it (``num-computed-beliefs``), + and the number saved to the database (``num-saved-beliefs``), which leaves out beliefs that were already on record. + The ``num-beliefs`` field holds the total number of beliefs passed to the save. - For a finished reporting job, ``result`` is a list holding, per output, - the ``sensor_id``, the number of beliefs computed (``n_computed``) and the number of beliefs saved (``n_rows``). + For a finished reporting job, ``result`` is an object holding the same ``outputs`` field, with one entry per output sensor of the report. security: - ApiKeyAuth: [] parameters: @@ -138,11 +136,10 @@ def get_job_status(self, job_id: str, **kwargs): For a finished scheduling job, this is an object; a ``StorageScheduler`` job populates it with ``unresolved``/``resolved`` soft state-of-charge constraint analysis (empty arrays when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used). - The ``num-computed-beliefs`` field holds the total number of beliefs (scheduled values) computed, - and ``num-saved-beliefs`` the total number saved to the database. - The ``num-beliefs`` field is deprecated: it equals ``num-computed-beliefs``. - For a finished reporting job, this is a list holding, per output, - ``sensor_id``, ``n_computed`` and ``n_rows`` (the number of beliefs saved). + Its ``outputs`` field lists, per sensor the schedule records on, the ``sensor`` (its ID), + ``num-computed-beliefs`` and ``num-saved-beliefs``, + and its ``num-beliefs`` field holds the total number of beliefs passed to the save. + For a finished reporting job, this is an object holding the same ``outputs`` field. nullable: true func-name: type: string @@ -223,8 +220,10 @@ def get_job_status(self, job_id: str, **kwargs): - datetime: "2024-01-01T10:15:00+00:00" violation: "180.0 kWh" resolved: [] - num-computed-beliefs: 96 - num-saved-beliefs: 96 + outputs: + - sensor: 5 + num-computed-beliefs: 96 + num-saved-beliefs: 96 num-beliefs: 96 func-name: "flexmeasures.data.services.scheduling.create_schedule" origin: scheduling diff --git a/flexmeasures/api/v3_0/tests/test_jobs_api.py b/flexmeasures/api/v3_0/tests/test_jobs_api.py index 2890607211..6e4ddb3ed7 100644 --- a/flexmeasures/api/v3_0/tests/test_jobs_api.py +++ b/flexmeasures/api/v3_0/tests/test_jobs_api.py @@ -255,9 +255,10 @@ def test_get_job_status_finished( result = data["result"] assert result["unresolved"] == [] assert result["resolved"] == [] - assert result["num-computed-beliefs"] == 96 - assert result["num-saved-beliefs"] == 96 - # The deprecated num-beliefs keeps counting the beliefs passed to the save. + assert set(result.keys()) == {"unresolved", "resolved", "outputs", "num-beliefs"} + assert result["outputs"] == [ + {"sensor": sensor.id, "num-computed-beliefs": 96, "num-saved-beliefs": 96} + ] assert result["num-beliefs"] == 96 assert data["exc-info"] is None assert_legacy_job_status_fields(data) @@ -321,14 +322,14 @@ def test_get_job_status_finished_with_unresolved_soc_minima( assert set(result.keys()) == { "unresolved", "resolved", + "outputs", "num-beliefs", - "num-computed-beliefs", - "num-saved-beliefs", } - assert isinstance(result["num-saved-beliefs"], int) - assert result["num-saved-beliefs"] > 0 - assert result["num-computed-beliefs"] == result["num-saved-beliefs"] - assert result["num-beliefs"] == result["num-computed-beliefs"] + assert isinstance(result["num-beliefs"], int) + assert result["num-beliefs"] > 0 + assert result["num-beliefs"] == sum( + output["num-computed-beliefs"] for output in result["outputs"] + ) asset_id = add_battery_assets["Test battery"].id unresolved_entry = next( diff --git a/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py b/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py index f603677829..4b02137933 100644 --- a/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py @@ -62,6 +62,22 @@ def test_trigger_report_worker_stores_report_data( # Adding the two hourly 0-9 input series yields 0, 2, ..., 18; two-hour mean resampling yields 1, 5, 9, 13, 17. assert stored_report.values.T.tolist() == [[1, 5, 9, 13, 17]] + # The job's result, as the API hands it out, lists the report's output with what it computed and what it saved. + with app.test_client() as client: + job_response = client.get( + url_for("JobAPI:get_job_status", uuid=response.json["job"]) + ) + assert job_response.status_code == 200 + result = job_response.json["result"] + assert set(result.keys()) == {"outputs"} + assert result["outputs"] == [ + { + "sensor": sensors["output"].id, + "num-computed-beliefs": 5, + "num-saved-beliefs": 5, + } + ] + @pytest.mark.parametrize( "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True @@ -128,13 +144,15 @@ def test_trigger_report_worker_reports_nothing_saved_for_misaligned_inputs( assert job.get_status() == "finished" # The saved count must exclude computed rows that persistence drops as NaN, while the computed count includes them: # the two inputs do not align on source, so adding them gives a NaN for each of their 2 x 10 rows. - assert job.return_value() == [ - { - "sensor_id": hourly_output.id, - "n_computed": 20, - "n_rows": 0, - } - ] + assert job.return_value() == { + "outputs": [ + { + "sensor": hourly_output.id, + "num-computed-beliefs": 20, + "num-saved-beliefs": 0, + } + ] + } assert any( "produced no persistable values" in record.message for record in caplog.records ), "an empty report should be warned about" diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index 0bfd50b9d4..b58643a17f 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -2204,8 +2204,12 @@ def add_schedule( # noqa C901 dry_run=dry_run, **scheduling_kwargs, ) - n_computed = scheduling_result["num-computed-beliefs"] - n_saved = scheduling_result["num-saved-beliefs"] + n_computed = sum( + output["num-computed-beliefs"] for output in scheduling_result["outputs"] + ) + n_saved = sum( + output["num-saved-beliefs"] for output in scheduling_result["outputs"] + ) if dry_run: click.secho( f"The schedule above was computed but not stored (because of --dry-run): computed {n_computed}, saved 0.", diff --git a/flexmeasures/cli/tests/test_automations.py b/flexmeasures/cli/tests/test_automations.py index 391b4b0ca4..336ab3133a 100644 --- a/flexmeasures/cli/tests/test_automations.py +++ b/flexmeasures/cli/tests/test_automations.py @@ -1991,10 +1991,20 @@ def test_run_report_automation_with_two_outputs_saves_both( ) assert (stored_sub.values.T == [0, 0, 0, 0, 0]).all() - assert job.return_value() == [ - {"sensor_id": report_sensor_id, "n_computed": 5, "n_rows": 5}, - {"sensor_id": report_sensor_2_id, "n_computed": 5, "n_rows": 5}, - ] + assert job.return_value() == { + "outputs": [ + { + "sensor": report_sensor_id, + "num-computed-beliefs": 5, + "num-saved-beliefs": 5, + }, + { + "sensor": report_sensor_2_id, + "num-computed-beliefs": 5, + "num-saved-beliefs": 5, + }, + ] + } import pandas as pd @@ -2249,7 +2259,7 @@ def test_a_rerun_report_job_counts_what_it_saved_and_says_why_it_saved_nothing( from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.services.reporting import run_report_job - report_sensor_id = setup_dummy_data_fresh_db[2] + *_, report_sensor_id, report_sensor_2_id = setup_dummy_data_fresh_db report_sensor = fresh_db.session.get(Sensor, report_sensor_id) runner = app.test_cli_runner() freeze_server_now(datetime(2023, 4, 10, 10, 0, 30, tzinfo=timezone.utc)) @@ -2266,18 +2276,24 @@ def test_a_rerun_report_job_counts_what_it_saved_and_says_why_it_saved_nothing( ) first = run_report_job(**job.kwargs) - assert all(result["n_rows"] > 0 for result in first), first - assert all(result["n_computed"] == result["n_rows"] for result in first), first + assert first == { + "outputs": [ + {"sensor": sensor_id, "num-computed-beliefs": 5, "num-saved-beliefs": 5} + for sensor_id in (report_sensor_id, report_sensor_2_id) + ] + } info = mocker.spy(app.logger, "info") warning = mocker.spy(app.logger, "warning") second = run_report_job(**job.kwargs) - assert [result["n_rows"] for result in second] == [0] * len(first) # The rerun computed as much as the first run did, which tells it apart from a run that computed nothing. - assert [result["n_computed"] for result in second] == [ - result["n_computed"] for result in first - ] + assert second == { + "outputs": [ + {"sensor": sensor_id, "num-computed-beliefs": 5, "num-saved-beliefs": 0} + for sensor_id in (report_sensor_id, report_sensor_2_id) + ] + } assert any( "already on record" in call.args[0] for call in info.call_args_list ), info.call_args_list diff --git a/flexmeasures/cli/tests/test_data_add_fresh_db.py b/flexmeasures/cli/tests/test_data_add_fresh_db.py index 320a1034c0..25f67c7fd8 100644 --- a/flexmeasures/cli/tests/test_data_add_fresh_db.py +++ b/flexmeasures/cli/tests/test_data_add_fresh_db.py @@ -1555,42 +1555,6 @@ def test_add_schedule_dry_run_saves_no_beliefs( assert len(sensor.search_beliefs()) == n_computed -def test_schedule_results_count_computed_and_saved_beliefs_and_keep_num_beliefs( - app, fresh_db, process_power_sensor, add_market_prices_fresh_db, monkeypatch -): - """A schedule's result counts the beliefs computed and those saved, and the deprecated num-beliefs keeps its v1.0 meaning. - - That meaning is the number of beliefs passed to the save: none on a dry run, and all computed beliefs otherwise, - also on a rerun that saves none of them, because they are already on record. - """ - from flexmeasures.cli import data_add - - results = [] - real_make_schedule = data_add.make_schedule - - def make_schedule_and_keep_result(**kwargs): - result = real_make_schedule(**kwargs) - results.append(result) - return result - - monkeypatch.setattr(data_add, "make_schedule", make_schedule_and_keep_result) - runner = app.test_cli_runner() - cli_input = to_flags(_process_schedule_cli_input(fresh_db, process_power_sensor)) - - for flags in (["--dry-run"], [], []): - check_command_ran_without_error( - runner.invoke(data_add.add_schedule, cli_input + flags) - ) - - n = results[0]["num-computed-beliefs"] - assert n > 0 - counts = [ - (r["num-computed-beliefs"], r["num-saved-beliefs"], r["num-beliefs"]) - for r in results - ] - assert counts == [(n, 0, 0), (n, n, n), (n, 0, n)] - - def test_add_schedule_says_when_the_scheduler_computed_nothing( app, fresh_db, process_power_sensor, add_market_prices_fresh_db, monkeypatch ): diff --git a/flexmeasures/data/automations.py b/flexmeasures/data/automations.py index 3d15af6b58..83df740c9a 100644 --- a/flexmeasures/data/automations.py +++ b/flexmeasures/data/automations.py @@ -402,12 +402,18 @@ def check_execution_access(automation, sensors): @record_outcome_on_automation_run -def execute_automation_job(automation_id: int, data_source_id: int, parameters: dict): - """Compute and persist declared results with provenance in a plugin-enabled worker.""" +def execute_automation_job( + automation_id: int, data_source_id: int, parameters: dict +) -> dict: + """Compute and persist declared results with provenance in a plugin-enabled worker. + + The job's result lists its outputs as a report job's result does (see `run_report_job`). + """ from flexmeasures.data.models.automations import Automation from flexmeasures.data.services.generator_results import ( check_generator_results, describe_generator, + outputs_of_job_result, save_generator_results, ) @@ -433,4 +439,4 @@ def execute_automation_job(automation_id: int, data_source_id: int, parameters: ) saved = save_generator_results(results) db.session.commit() - return saved + return {"outputs": outputs_of_job_result(saved)} diff --git a/flexmeasures/data/services/generator_results.py b/flexmeasures/data/services/generator_results.py index 2baf58d5a2..9fdc47aaf2 100644 --- a/flexmeasures/data/services/generator_results.py +++ b/flexmeasures/data/services/generator_results.py @@ -178,3 +178,19 @@ def save_results_not_yet_saved(results: list[dict]) -> None: return for result, saved in zip(unsaved, save_generator_results(unsaved)): result["n_saved"] = saved["n_rows"] + + +def outputs_of_job_result(saved: list[dict]) -> list[dict]: + """Turn what ``save_generator_results`` says about each result into the "outputs" a job result reports, as API clients read them. + + Each output names its sensor (by ID), the number of beliefs computed ("num-computed-beliefs") and the number of beliefs saved ("num-saved-beliefs"), + counted as ``save_generator_results`` counts them. + """ + return [ + { + "sensor": entry["sensor_id"], + "num-computed-beliefs": entry["n_computed"], + "num-saved-beliefs": entry["n_rows"], + } + for entry in saved + ] diff --git a/flexmeasures/data/services/reporting.py b/flexmeasures/data/services/reporting.py index a770d082ee..75d80745c3 100644 --- a/flexmeasures/data/services/reporting.py +++ b/flexmeasures/data/services/reporting.py @@ -15,6 +15,7 @@ from flexmeasures.data.services.generator_results import ( check_generator_results, describe_generator, + outputs_of_job_result, save_generator_results, ) @@ -140,9 +141,12 @@ def compute_and_save_report( @record_outcome_on_automation_run -def run_report_job(data_source_id: int, parameters: dict) -> list[dict]: +def run_report_job(data_source_id: int, parameters: dict) -> dict: """Compute and store a report in a reporting worker. + The job's result lists, under "outputs", one entry per output sensor: + the sensor (by ID), the number of beliefs computed ("num-computed-beliefs") and the number of beliefs saved ("num-saved-beliefs"). + If the report was triggered by an automation, the end of the report window is recorded upon success, so the automation's next default window starts where this one ended. A failed report job therefore leaves no permanent gap in the reported periods. @@ -214,4 +218,4 @@ def run_report_job(data_source_id: int, parameters: dict) -> list[dict]: automation_id, now=datetime.fromisoformat(parameters["end"]) ) - return saved + return {"outputs": outputs_of_job_result(saved)} diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 4426b597fb..38898f6638 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -44,6 +44,7 @@ from flexmeasures.data.services.generator_results import ( check_generator_results, describe_generator, + outputs_of_job_result, save_generator_results, ) from flexmeasures.utils.time_utils import server_now @@ -972,8 +973,8 @@ def make_schedule( # noqa: C901 ) -> dict: """ This function computes a schedule. It returns a dict (see ``SchedulingJobResult``), - which counts the beliefs the schedule computed (``num-computed-beliefs``) and those it saved (``num-saved-beliefs``). - It also holds the deprecated ``num-beliefs``, which counts the beliefs passed to the save, as it did in v1.0. + which lists, per output sensor, the beliefs the schedule computed and those it saved (under ``outputs``), + and counts the beliefs passed to the save (``num-beliefs``). If the scheduler produced soft state-of-charge constraint analysis, the dict also holds that analysis under ``unresolved`` and ``resolved`` keys, which are empty lists otherwise. @@ -1101,7 +1102,8 @@ def make_schedule( # noqa: C901 ) scheduling_result_dict: dict = SchedulingJobResult().to_dict() to_save = [] - n_computed = 0 + # On a dry run, nothing is saved, so the outputs say what was computed, and that none of it was saved. + dry_run_outputs = [] for result in consumption_schedule: if result.get("name") == SCHEDULING_RESULT_KEY: scheduling_result_dict = result["data"].to_dict() @@ -1144,10 +1146,12 @@ def make_schedule( # noqa: C901 # todo: move this into save_to_db bdf = bdf.resample_events(bdf.sensor.event_resolution) - n_computed += len(bdf) if not dry_run: to_save.append({"sensor": result["sensor"], "data": bdf}) else: + dry_run_outputs.append( + {"sensor_id": result["sensor"].id, "n_computed": len(bdf), "n_rows": 0} + ) # Report what would have been saved, in the same terms as a forecast dry run does: # the sensor, the number of beliefs, and the events they cover. event_range = ( @@ -1161,14 +1165,11 @@ def make_schedule( # noqa: C901 ) # Saved the way every data generator's results are: all or nothing, and counting the beliefs actually saved, - # which leaves out beliefs that repeat what is already on record. On a dry run nothing is saved, so that count is 0. - # The beliefs computed are counted as well, also on a dry run, so that saving none does not read as computing none. + # which leaves out beliefs that repeat what is already on record. + # Each output also counts the beliefs computed, so that saving none does not read as computing none. saved = save_generator_results(to_save) if to_save else [] - scheduling_result_dict["num-computed-beliefs"] = n_computed - scheduling_result_dict["num-saved-beliefs"] = sum( - entry["n_rows"] for entry in saved - ) - # The deprecated num-beliefs keeps its v1.0 meaning: the beliefs passed to the save, which are none on a dry run. + scheduling_result_dict["outputs"] = outputs_of_job_result(saved + dry_run_outputs) + # num-beliefs counts the beliefs passed to the save, as it has since v1.0, which are none on a dry run. scheduling_result_dict["num-beliefs"] = sum(entry["n_computed"] for entry in saved) if not dry_run: diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index c569d3a0fd..797318281f 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -13,19 +13,18 @@ class SchedulingJobResult: The sensor schedule endpoint (``GET /api/v3_0/sensors//schedules/``) returns power values only and does not include constraint analysis. **Structure:** - Results contain three top-level fields: + Results contain four top-level fields: - ``unresolved``: List of soft constraints that the scheduler could not satisfy - Each entry is a dict with ``"asset"`` field (asset ID) and constraint-type keys (``"soc-minima"``, ``"soc-maxima"``) - Each constraint-type key holds a list of dicts, one per violated slot (chronologically ordered): ``{"datetime": ISO 8601 UTC, "violation": "X kWh"}`` - ``resolved``: List of soft constraints that were satisfied with available headroom - Each entry is a dict with ``"asset"`` field and constraint-type keys - Each constraint-type key holds a list of dicts, one per met slot (chronologically ordered): ``{"datetime": ISO 8601 UTC, "margin": "X kWh"}`` - - ``num-computed-beliefs``: Total number of beliefs (scheduled values) the scheduler computed, - including those that were not saved because they were already on record. - - ``num-saved-beliefs``: Total number of beliefs (scheduled values) saved to the database, - which leaves out beliefs that repeat what is already on record. - - ``num-beliefs``: Deprecated, kept with its v1.0 meaning: - the total number of beliefs passed to the save, which equals ``num-computed-beliefs``, except on a dry run, where it is 0. + - ``outputs``: List of the sensors the schedule records on, one entry per sensor + - Each entry is a dict with ``"sensor"`` field (sensor ID), ``"num-computed-beliefs"`` (the beliefs the scheduler computed for it), + and ``"num-saved-beliefs"`` (those saved, which leaves out beliefs that repeat what is already on record). + - On a dry run, nothing is saved, so each entry's ``"num-saved-beliefs"`` is 0. + - ``num-beliefs``: Total number of beliefs (scheduled values) passed to the save, which is 0 on a dry run. **Important:** ``soc-targets`` (hard constraints) are never included since they are strictly enforced by the scheduler. Only hard constraint failures cause job failure. @@ -50,8 +49,9 @@ class SchedulingJobResult: ], } ], - "num-computed-beliefs": 96, - "num-saved-beliefs": 96, + "outputs": [ + {"sensor": 5, "num-computed-beliefs": 96, "num-saved-beliefs": 96}, + ], "num-beliefs": 96 } @@ -60,9 +60,7 @@ class SchedulingJobResult: unresolved: list = field(default_factory=list) resolved: list = field(default_factory=list) - num_computed_beliefs: int = 0 - num_saved_beliefs: int = 0 - #: Deprecated, see ``num-beliefs`` above. + outputs: list = field(default_factory=list) num_beliefs: int = 0 def to_dict(self) -> dict: @@ -70,8 +68,7 @@ def to_dict(self) -> dict: return { "unresolved": self.unresolved, "resolved": self.resolved, - "num-computed-beliefs": self.num_computed_beliefs, - "num-saved-beliefs": self.num_saved_beliefs, + "outputs": self.outputs, "num-beliefs": self.num_beliefs, } @@ -81,7 +78,6 @@ def from_dict(cls, d: dict) -> "SchedulingJobResult": return cls( unresolved=d.get("unresolved", []), resolved=d.get("resolved", []), - num_computed_beliefs=d.get("num-computed-beliefs", 0), - num_saved_beliefs=d.get("num-saved-beliefs", 0), + outputs=d.get("outputs", []), num_beliefs=d.get("num-beliefs", 0), ) diff --git a/flexmeasures/data/tests/test_plugin_automations_fresh_db.py b/flexmeasures/data/tests/test_plugin_automations_fresh_db.py index 7a28630949..2ccb10f87a 100644 --- a/flexmeasures/data/tests/test_plugin_automations_fresh_db.py +++ b/flexmeasures/data/tests/test_plugin_automations_fresh_db.py @@ -222,8 +222,24 @@ def test_plugin_job_reports_rows_saved( assert job.is_finished, job.exc_info n_rows.append(job.return_value()) assert n_rows == [ - [{"sensor_id": sensors[0].id, "n_computed": 1, "n_rows": 1}], - [{"sensor_id": sensors[0].id, "n_computed": 1, "n_rows": 0}], + { + "outputs": [ + { + "sensor": sensors[0].id, + "num-computed-beliefs": 1, + "num-saved-beliefs": 1, + } + ] + }, + { + "outputs": [ + { + "sensor": sensors[0].id, + "num-computed-beliefs": 1, + "num-saved-beliefs": 0, + } + ] + }, ] diff --git a/flexmeasures/data/tests/test_scheduling_jobs.py b/flexmeasures/data/tests/test_scheduling_jobs.py index 74391c7fd1..06b12e61c0 100644 --- a/flexmeasures/data/tests/test_scheduling_jobs.py +++ b/flexmeasures/data/tests/test_scheduling_jobs.py @@ -22,6 +22,7 @@ create_scheduling_job, load_custom_scheduler, handle_scheduling_exception, + make_schedule, ) from flexmeasures.utils.unit_utils import ur from flexmeasures.utils.calculations import integrate_time_series @@ -425,6 +426,74 @@ def test_save_state_of_charge( ) +def test_schedule_result_lists_per_output_what_was_computed_and_saved( + fresh_db, app, smart_building +): + """A schedule's result lists, per output sensor, the beliefs computed and those saved, and num-beliefs counts those passed to the save. + + A heat buffer with a state-of-charge sensor records on two sensors: its power and its state of charge. + A dry run saves none of what it computes, a first run saves all of it, + and a rerun computes the same schedule, which is already on record, so it saves none of it. + num-beliefs counts the beliefs passed to the save, as it did in v1.0: none on a dry run, and all of them on a rerun. + """ + assets, sensors, soc_sensors = smart_building + power_sensor = sensors["Test Heat Buffer"] + soc_sensor = soc_sensors["Test Heat Buffer"] + kwargs = dict( + asset_or_sensor={"id": power_sensor.id, "class": "Sensor"}, + start=pd.Timestamp("2015-01-03").tz_localize("Europe/Amsterdam"), + end=pd.Timestamp("2015-01-04").tz_localize("Europe/Amsterdam"), + belief_time=pd.Timestamp("2015-01-02").tz_localize("Europe/Amsterdam"), + resolution=timedelta(minutes=15), + scheduler_specs={ + "module": "flexmeasures.data.models.planning.storage", + "class": "StorageScheduler", + }, + flex_model={ + "power-capacity": "10kW", + "soc-at-start": "0kWh", + "soc-unit": "kWh", + "soc-min": 0.0, + "soc-max": "100kWh", + "soc-usage": ["10kW"], + "soc-gain": ["1kW"], + "state-of-charge": {"sensor": soc_sensor.id}, + }, + flex_context={ + "consumption-price": "100 EUR/MWh", + "production-price": "0 EUR/MWh", + "site-production-capacity": "1MW", + "site-consumption-capacity": "1MW", + }, + ) + + dry_run = make_schedule(dry_run=True, **kwargs) + first = make_schedule(**kwargs) + rerun = make_schedule(**kwargs) + + def counts(result: dict) -> dict: + return { + output["sensor"]: ( + output["num-computed-beliefs"], + output["num-saved-beliefs"], + ) + for output in result["outputs"] + } + + computed = {sensor: n for sensor, (n, _) in counts(first).items()} + assert set(computed) == {power_sensor.id, soc_sensor.id} + assert all(n > 0 for n in computed.values()) + assert counts(dry_run) == {sensor: (n, 0) for sensor, n in computed.items()} + assert counts(first) == {sensor: (n, n) for sensor, n in computed.items()} + assert counts(rerun) == {sensor: (n, 0) for sensor, n in computed.items()} + total = sum(computed.values()) + assert [dry_run["num-beliefs"], first["num-beliefs"], rerun["num-beliefs"]] == [ + 0, + total, + total, + ] + + def test_save_state_of_charge_percent_sensor( fresh_db, app, diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 0716f3cd7b..f45fbea4b7 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5341,7 +5341,7 @@ "/api/v3_0/jobs/{uuid}": { "get": { "summary": "Get the status of a background job", - "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe num-computed-beliefs field holds the total number of beliefs (scheduled values) the scheduler computed,\nand num-saved-beliefs the total number saved to the database,\nwhich leaves out beliefs not saved because they were already on record.\nThe num-beliefs field is deprecated in favour of these two:\nit holds the number of beliefs passed to the save, as it did before, which equals num-computed-beliefs.\n\nFor a finished reporting job, result is a list holding, per output,\nthe sensor_id, the number of beliefs computed (n_computed) and the number of beliefs saved (n_rows).\n", + "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe outputs field lists one entry per sensor the schedule records on:\nthe sensor (its ID), the number of beliefs (scheduled values) the scheduler computed for it (num-computed-beliefs),\nand the number saved to the database (num-saved-beliefs), which leaves out beliefs that were already on record.\nThe num-beliefs field holds the total number of beliefs passed to the save.\n\nFor a finished reporting job, result is an object holding the same outputs field, with one entry per output sensor of the report.\n", "security": [ { "ApiKeyAuth": [] @@ -5386,7 +5386,7 @@ "description": "Human-readable description of the job status." }, "result": { - "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). The num-computed-beliefs field holds the total number of beliefs (scheduled values) computed, and num-saved-beliefs the total number saved to the database. The num-beliefs field is deprecated: it equals num-computed-beliefs. For a finished reporting job, this is a list holding, per output, sensor_id, n_computed and n_rows (the number of beliefs saved).\n", + "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). Its outputs field lists, per sensor the schedule records on, the sensor (its ID), num-computed-beliefs and num-saved-beliefs, and its num-beliefs field holds the total number of beliefs passed to the save. For a finished reporting job, this is an object holding the same outputs field.\n", "nullable": true }, "func-name": { @@ -5491,8 +5491,13 @@ } ], "resolved": [], - "num-computed-beliefs": 96, - "num-saved-beliefs": 96, + "outputs": [ + { + "sensor": 5, + "num-computed-beliefs": 96, + "num-saved-beliefs": 96 + } + ], "num-beliefs": 96 }, "func-name": "flexmeasures.data.services.scheduling.create_schedule", From 9f6e404a281956b15c733e3a83b52db16df95b0c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 17:52:38 +0100 Subject: [PATCH 08/11] Call the beliefs a job saved its recorded beliefs in job results and messages The outputs of a reporting, scheduling or plugin automation job's result say num-recorded-beliefs, where they said num-saved-beliefs, which was never released. add schedule, add report and the reporting job's log say "recorded" where they report these counts, and so do the changelogs. Internal names, such as save_generator_results and its n_rows, stay as they are. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 2 +- documentation/changelog.rst | 2 +- documentation/cli/change_log.rst | 4 ++-- flexmeasures/api/v3_0/jobs.py | 6 ++--- flexmeasures/api/v3_0/tests/test_jobs_api.py | 2 +- .../tests/test_report_trigger_api_fresh_db.py | 4 ++-- flexmeasures/cli/data_add.py | 24 +++++++++---------- flexmeasures/cli/tests/test_automations.py | 8 +++---- .../cli/tests/test_data_add_fresh_db.py | 12 +++++----- .../data/services/generator_results.py | 4 ++-- flexmeasures/data/services/reporting.py | 8 +++---- .../data/services/scheduling_result.py | 6 ++--- .../tests/test_plugin_automations_fresh_db.py | 4 ++-- .../data/tests/test_scheduling_jobs.py | 2 +- flexmeasures/ui/static/openapi-specs.json | 6 ++--- 15 files changed, 47 insertions(+), 47 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 8d081f247c..752dcec5df 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -44,7 +44,7 @@ v3.0-34 | November XX, 2026 - ``GET /api/v3_0/assets//jobs`` now accepts ``page`` and ``per-page`` to return jobs newest first, with ``num-records`` and ``filtered-records`` counts. Paginated results can be sorted by enqueue time or queue with ``sort-by`` and ``sort-dir``. Requests without ``page`` retain the full list and previous response shape. - ``GET /api/v3_0/assets//jobs`` also lists the jobs of the assets below the asset, at any depth, as far as the caller may read them; pass ``include-child-assets=false`` to list only the asset's own jobs. Its entries include ``created_via`` provenance, and the automation that created a job only where the caller may read that automation. -- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed and how many it saved, per output sensor. A reporting job's ``result`` is an object holding an ``outputs`` list, and a scheduling job's ``result`` gains the same ``outputs`` list, next to its unchanged ``num-beliefs``. Each entry holds the ``sensor`` (its ID), ``num-computed-beliefs`` and ``num-saved-beliefs``. The saved count leaves out beliefs that were already on record (and NaN values), so a rerun that only repeats what is on record saves 0 of the beliefs it computed. +- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed and how many it recorded, per output sensor. A reporting job's ``result`` is an object holding an ``outputs`` list, and a scheduling job's ``result`` gains the same ``outputs`` list, next to its unchanged ``num-beliefs``. Each entry holds the ``sensor`` (its ID), ``num-computed-beliefs`` and ``num-recorded-beliefs``. The recorded count leaves out beliefs that were already on record (and NaN values), so a rerun that only repeats what is on record records 0 of the beliefs it computed. - ``GET /api/v3_0/assets//jobs`` renames its ``redis_connection_err`` field, which shipped in v1.0.0, to ``redis-connection-err``, as the automation endpoints spell it. It is read by FlexMeasures' own UI rather than by the FlexMeasures client. - ``GET /api/v3_0/assets`` now scopes a listing the way its parameters say. ``num-records`` respects the ``root`` and ``depth`` constraints, where it used to count every asset in the account and asset-type scope. And an explicit ``include_public=false`` is now honoured, also next to ``all_accessible`` or ``root``; leaving the parameter out keeps the behaviour it had, where ``all_accessible`` and ``root`` include public assets and a listing of a single account does not. - ``GET /api/v3_0/sensors//status`` now accepts an optional ``asset_id`` query parameter, naming the asset whose status page the sensor is reported on. It only affects the reported ``relation``, which used to claim that the sensor belongs to the asset shown, also for a sensor that is only listed because the asset's flex-context or graphs page refers to it. Reading the status of a sensor in the context of an asset requires read permission on that asset, too. diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 042a79db76..534acc4226 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -191,7 +191,7 @@ Bugfixes * An ``AggregatorReporter`` input that holds more than one belief about an event, such as one read with ``most-recent-beliefs-only: false``, is now refused with a message naming the sensor and the event, where it used to fail with a ``KeyError`` [see `PR #2741 `_] * A ``PandasReporter`` with ``droplevels`` no longer fails with ``BeliefDataframe has more than one row per event`` when several data sources reported the same input event, as happens for a while when a job reporting on a rolling window gets a new data source; it keeps one belief per event, unless an input sets ``one_deterministic_belief_per_event`` to ``false`` [see `PR #2595 `_] -* The results of reporting and scheduling jobs now list, per output sensor, the beliefs computed and those saved, so that a rerun which only repeats what is on record reads as having saved nothing, rather than as having computed nothing; a schedule's ``num-beliefs`` is unchanged [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] +* The results of reporting and scheduling jobs now list, per output sensor, the beliefs computed and those recorded, so that a rerun which only repeats what is on record reads as having recorded nothing, rather than as having computed nothing; a schedule's ``num-beliefs`` is unchanged [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] * A forecaster registered by a plugin no longer needs to save its forecasts itself, but can return them instead: ``flexmeasures add forecasts`` now saves them, where it used to drop them while reporting them as created [see `PR #2689 `_ and `PR #2571 `_] * Running the same forecast twice from the same start no longer fails on the beliefs it already recorded [see `PR #2688 `_] * Whether a forecaster takes a regressor's belief as realized now follows the knowledge horizon of the regressor's sensor, as in timely-beliefs, so that the past readings of an instantaneous regressor (such as a thermometer read on the hour) and day-ahead prices (beliefs about future periods, which count as realized once the auction has cleared) are no longer kept from the model [see `PR #2692 `_] diff --git a/documentation/cli/change_log.rst b/documentation/cli/change_log.rst index c1d1f74ef3..d6f423253d 100644 --- a/documentation/cli/change_log.rst +++ b/documentation/cli/change_log.rst @@ -35,14 +35,14 @@ since v1.1.0 | November XX, 2026 * Add ``flexmeasures add report --as-job`` and the ``reporting`` worker queue for asynchronous one-off reports. * Add ``flexmeasures add forecasts --dry-run`` to compute a forecast and see what it would record, without saving anything to the database. It cannot be combined with ``--as-job``. * ``flexmeasures add report`` saves the reporter's config on its data source by default. As a report then records under a new source, anything referring to its old source by ID should be pointed at the new one after the first run. Pass ``--no-save-config`` to leave the config out and keep recording under the old source; ``--save-config`` is still accepted. -* ``flexmeasures add report`` saves all of a report's outputs or none of them, where each output used to be committed on its own, and says for each output how many beliefs it computed and how many of those it saved. +* ``flexmeasures add report`` saves all of a report's outputs or none of them, where each output used to be committed on its own, and says for each output how many beliefs it computed and how many of those it recorded. * ``flexmeasures add forecasts`` saves the forecasts of a forecaster that a plugin registers, which used to be dropped while the command reported them as created. * ``flexmeasures add forecasts`` no longer fails when it runs again from the same ``--from-date``, and says how many of the forecast beliefs it computed were new and saved. * ``flexmeasures add forecasts`` no longer keeps every trained model as a file; pass ``--model-save-dir`` to keep them. **Scheduling** -* ``flexmeasures add schedule`` says how many beliefs it computed and how many of those it saved, so that a rerun which only repeats what is on record no longer reads as storing a new schedule. +* ``flexmeasures add schedule`` says how many beliefs it computed and how many of those it recorded, so that a rerun which only repeats what is on record no longer reads as storing a new schedule. * Fix ``flexmeasures add schedule --dry-run`` saving a schedule when combined with ``--as-job``: the combination is now rejected, and a dry run reports the beliefs and events it would have saved. **Assets** diff --git a/flexmeasures/api/v3_0/jobs.py b/flexmeasures/api/v3_0/jobs.py index c740116039..e7c16a007a 100644 --- a/flexmeasures/api/v3_0/jobs.py +++ b/flexmeasures/api/v3_0/jobs.py @@ -93,7 +93,7 @@ def get_job_status(self, job_id: str, **kwargs): the sensor schedule endpoint (``GET /api/v3_0/sensors//schedules/``) returns power values only. The ``outputs`` field lists one entry per sensor the schedule records on: the ``sensor`` (its ID), the number of beliefs (scheduled values) the scheduler computed for it (``num-computed-beliefs``), - and the number saved to the database (``num-saved-beliefs``), which leaves out beliefs that were already on record. + and the number recorded in the database (``num-recorded-beliefs``), which leaves out beliefs that were already on record. The ``num-beliefs`` field holds the total number of beliefs passed to the save. For a finished reporting job, ``result`` is an object holding the same ``outputs`` field, with one entry per output sensor of the report. @@ -137,7 +137,7 @@ def get_job_status(self, job_id: str, **kwargs): a ``StorageScheduler`` job populates it with ``unresolved``/``resolved`` soft state-of-charge constraint analysis (empty arrays when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used). Its ``outputs`` field lists, per sensor the schedule records on, the ``sensor`` (its ID), - ``num-computed-beliefs`` and ``num-saved-beliefs``, + ``num-computed-beliefs`` and ``num-recorded-beliefs``, and its ``num-beliefs`` field holds the total number of beliefs passed to the save. For a finished reporting job, this is an object holding the same ``outputs`` field. nullable: true @@ -223,7 +223,7 @@ def get_job_status(self, job_id: str, **kwargs): outputs: - sensor: 5 num-computed-beliefs: 96 - num-saved-beliefs: 96 + num-recorded-beliefs: 96 num-beliefs: 96 func-name: "flexmeasures.data.services.scheduling.create_schedule" origin: scheduling diff --git a/flexmeasures/api/v3_0/tests/test_jobs_api.py b/flexmeasures/api/v3_0/tests/test_jobs_api.py index 6e4ddb3ed7..c8b32422e2 100644 --- a/flexmeasures/api/v3_0/tests/test_jobs_api.py +++ b/flexmeasures/api/v3_0/tests/test_jobs_api.py @@ -257,7 +257,7 @@ def test_get_job_status_finished( assert result["resolved"] == [] assert set(result.keys()) == {"unresolved", "resolved", "outputs", "num-beliefs"} assert result["outputs"] == [ - {"sensor": sensor.id, "num-computed-beliefs": 96, "num-saved-beliefs": 96} + {"sensor": sensor.id, "num-computed-beliefs": 96, "num-recorded-beliefs": 96} ] assert result["num-beliefs"] == 96 assert data["exc-info"] is None diff --git a/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py b/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py index 4b02137933..b57aa2ec5e 100644 --- a/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py +++ b/flexmeasures/api/v3_0/tests/test_report_trigger_api_fresh_db.py @@ -74,7 +74,7 @@ def test_trigger_report_worker_stores_report_data( { "sensor": sensors["output"].id, "num-computed-beliefs": 5, - "num-saved-beliefs": 5, + "num-recorded-beliefs": 5, } ] @@ -149,7 +149,7 @@ def test_trigger_report_worker_reports_nothing_saved_for_misaligned_inputs( { "sensor": hourly_output.id, "num-computed-beliefs": 20, - "num-saved-beliefs": 0, + "num-recorded-beliefs": 0, } ] } diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index b58643a17f..752a2f307d 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -2207,12 +2207,12 @@ def add_schedule( # noqa C901 n_computed = sum( output["num-computed-beliefs"] for output in scheduling_result["outputs"] ) - n_saved = sum( - output["num-saved-beliefs"] for output in scheduling_result["outputs"] + n_recorded = sum( + output["num-recorded-beliefs"] for output in scheduling_result["outputs"] ) if dry_run: click.secho( - f"The schedule above was computed but not stored (because of --dry-run): computed {n_computed}, saved 0.", + f"The schedule above was computed but not stored (because of --dry-run): computed {n_computed}, recorded 0.", **MsgStyle.SUCCESS, ) elif n_computed == 0: @@ -2220,19 +2220,19 @@ def add_schedule( # noqa C901 "The scheduler computed no beliefs, so no new schedule was stored.", **MsgStyle.WARN, ) - elif n_saved == n_computed: + elif n_recorded == n_computed: click.secho( - f"New schedule is stored: saved {pluralize('belief', n_saved, include_count=True)}.", + f"New schedule is stored: recorded {pluralize('belief', n_recorded, include_count=True)}.", **MsgStyle.SUCCESS, ) - elif n_saved: + elif n_recorded: click.secho( - f"New schedule is stored: computed {n_computed}, saved {n_saved}, as the rest was already on record.", + f"New schedule is stored: computed {n_computed}, recorded {n_recorded}, as the rest was already on record.", **MsgStyle.SUCCESS, ) else: click.secho( - f"The new schedule repeats beliefs already on record, so nothing new was stored (computed {n_computed}, saved 0).", + f"The new schedule repeats beliefs already on record, so nothing new was stored (computed {n_computed}, recorded 0).", **MsgStyle.SUCCESS, ) @@ -2531,23 +2531,23 @@ def add_report( # noqa: C901 n_computed = saved[i]["n_computed"] if n_saved and n_saved == n_computed: click.secho( - f"Success. Saved {pluralize('belief', n_saved, include_count=True)} of the report for sensor `{sensor}` to the database.", + f"Success. Recorded {pluralize('belief', n_saved, include_count=True)} of the report for sensor `{sensor}` in the database.", **MsgStyle.SUCCESS, ) elif n_saved: click.secho( - f"Success. Saved {n_saved} of the {pluralize('belief', n_computed, include_count=True)} computed for the report for sensor `{sensor}` to the database:" + f"Success. Recorded {n_saved} of the {pluralize('belief', n_computed, include_count=True)} computed for the report for sensor `{sensor}` in the database:" " the rest was already on record or had no value.", **MsgStyle.SUCCESS, ) elif count_persistable_values(data): click.secho( - f"The report for sensor `{sensor}` repeats beliefs already on record, so nothing new was saved (computed {n_computed}, saved 0).", + f"The report for sensor `{sensor}` repeats beliefs already on record, so nothing new was recorded (computed {n_computed}, recorded 0).", **MsgStyle.SUCCESS, ) else: click.secho( - f"Nothing was saved for sensor `{sensor}`, as the report holds no values to save (computed {n_computed}, saved 0)." + f"Nothing was recorded for sensor `{sensor}`, as the report holds no values to record (computed {n_computed}, recorded 0)." " This can happen when its inputs do not align on source and belief time.", **MsgStyle.WARN, ) diff --git a/flexmeasures/cli/tests/test_automations.py b/flexmeasures/cli/tests/test_automations.py index 336ab3133a..5f0d44255a 100644 --- a/flexmeasures/cli/tests/test_automations.py +++ b/flexmeasures/cli/tests/test_automations.py @@ -1996,12 +1996,12 @@ def test_run_report_automation_with_two_outputs_saves_both( { "sensor": report_sensor_id, "num-computed-beliefs": 5, - "num-saved-beliefs": 5, + "num-recorded-beliefs": 5, }, { "sensor": report_sensor_2_id, "num-computed-beliefs": 5, - "num-saved-beliefs": 5, + "num-recorded-beliefs": 5, }, ] } @@ -2278,7 +2278,7 @@ def test_a_rerun_report_job_counts_what_it_saved_and_says_why_it_saved_nothing( first = run_report_job(**job.kwargs) assert first == { "outputs": [ - {"sensor": sensor_id, "num-computed-beliefs": 5, "num-saved-beliefs": 5} + {"sensor": sensor_id, "num-computed-beliefs": 5, "num-recorded-beliefs": 5} for sensor_id in (report_sensor_id, report_sensor_2_id) ] } @@ -2290,7 +2290,7 @@ def test_a_rerun_report_job_counts_what_it_saved_and_says_why_it_saved_nothing( # The rerun computed as much as the first run did, which tells it apart from a run that computed nothing. assert second == { "outputs": [ - {"sensor": sensor_id, "num-computed-beliefs": 5, "num-saved-beliefs": 0} + {"sensor": sensor_id, "num-computed-beliefs": 5, "num-recorded-beliefs": 0} for sensor_id in (report_sensor_id, report_sensor_2_id) ] } diff --git a/flexmeasures/cli/tests/test_data_add_fresh_db.py b/flexmeasures/cli/tests/test_data_add_fresh_db.py index 25f67c7fd8..726dc9f958 100644 --- a/flexmeasures/cli/tests/test_data_add_fresh_db.py +++ b/flexmeasures/cli/tests/test_data_add_fresh_db.py @@ -1233,7 +1233,7 @@ def test_add_report_saves_beliefs(app, fresh_db, setup_dummy_data_fresh_db, tmp_ ), ) check_command_ran_without_error(result) - assert "Saved 5 beliefs of the report for sensor" in result.output + assert "Recorded 5 beliefs of the report for sensor" in result.output assert _count_beliefs(fresh_db, report_sensor_id) == beliefs_before + 5 report_sensor = fresh_db.session.get(Sensor, report_sensor_id) @@ -1305,10 +1305,10 @@ def test_add_report_says_when_a_rerun_saves_nothing_new( check_command_ran_without_error(rerun) assert ( - "repeats beliefs already on record, so nothing new was saved (computed 5, saved 0)" + "repeats beliefs already on record, so nothing new was recorded (computed 5, recorded 0)" in rerun.output ) - assert "Success. Saved" not in rerun.output + assert "Success. Recorded" not in rerun.output assert _count_beliefs(fresh_db, report_sensor_id) == recorded @@ -1536,20 +1536,20 @@ def test_add_schedule_dry_run_saves_no_beliefs( sensor = fresh_db.session.get(Sensor, process_power_sensor) assert len(sensor.search_beliefs()) == 0 # A dry run still says how much it computed, which a normal run then saves in full. - n_computed = int(re.search(r"computed (\d+), saved 0", result.output).group(1)) + n_computed = int(re.search(r"computed (\d+), recorded 0", result.output).group(1)) assert n_computed > 0 # A normal run does record the schedule, so the dry run really skipped that step result = runner.invoke(add_schedule, cli_input) check_command_ran_without_error(result) - assert f"New schedule is stored: saved {n_computed} beliefs." in result.output + assert f"New schedule is stored: recorded {n_computed} beliefs." in result.output assert len(sensor.search_beliefs()) == n_computed # Running it again computes the same schedule, which is already on record, so nothing new is saved. result = runner.invoke(add_schedule, cli_input) check_command_ran_without_error(result) assert ( - f"repeats beliefs already on record, so nothing new was stored (computed {n_computed}, saved 0)" + f"repeats beliefs already on record, so nothing new was stored (computed {n_computed}, recorded 0)" in result.output ) assert len(sensor.search_beliefs()) == n_computed diff --git a/flexmeasures/data/services/generator_results.py b/flexmeasures/data/services/generator_results.py index 9fdc47aaf2..99760e66fb 100644 --- a/flexmeasures/data/services/generator_results.py +++ b/flexmeasures/data/services/generator_results.py @@ -183,14 +183,14 @@ def save_results_not_yet_saved(results: list[dict]) -> None: def outputs_of_job_result(saved: list[dict]) -> list[dict]: """Turn what ``save_generator_results`` says about each result into the "outputs" a job result reports, as API clients read them. - Each output names its sensor (by ID), the number of beliefs computed ("num-computed-beliefs") and the number of beliefs saved ("num-saved-beliefs"), + Each output names its sensor (by ID), the number of beliefs computed ("num-computed-beliefs") and the number of beliefs recorded ("num-recorded-beliefs"), counted as ``save_generator_results`` counts them. """ return [ { "sensor": entry["sensor_id"], "num-computed-beliefs": entry["n_computed"], - "num-saved-beliefs": entry["n_rows"], + "num-recorded-beliefs": entry["n_rows"], } for entry in saved ] diff --git a/flexmeasures/data/services/reporting.py b/flexmeasures/data/services/reporting.py index 75d80745c3..ab4f23cf95 100644 --- a/flexmeasures/data/services/reporting.py +++ b/flexmeasures/data/services/reporting.py @@ -145,7 +145,7 @@ def run_report_job(data_source_id: int, parameters: dict) -> dict: """Compute and store a report in a reporting worker. The job's result lists, under "outputs", one entry per output sensor: - the sensor (by ID), the number of beliefs computed ("num-computed-beliefs") and the number of beliefs saved ("num-saved-beliefs"). + the sensor (by ID), the number of beliefs computed ("num-computed-beliefs") and the number of beliefs recorded ("num-recorded-beliefs"). If the report was triggered by an automation, the end of the report window is recorded upon success, so the automation's next default window starts where this one ended. @@ -182,19 +182,19 @@ def run_report_job(data_source_id: int, parameters: dict) -> dict: # Say both counts, as saving none of what was computed is not the same as computing nothing. summary = ", ".join( - f"sensor {result['sensor_id']}: computed {result['n_computed']}, saved {result['n_rows']}" + f"sensor {result['sensor_id']}: computed {result['n_computed']}, recorded {result['n_rows']}" for result in saved ) if any(result["n_rows"] for result in saved): current_app.logger.info( - "Report by %s ran successfully, saving the values that were not yet on record (%s).", + "Report by %s ran successfully, recording the values that were not yet on record (%s).", source, summary, ) elif any(count_persistable_values(result["data"]) for result in results): # Saving nothing is not the same as computing nothing: the values it computed were on record already, or NaN. current_app.logger.info( - "Report by %s ran successfully, but saved nothing new, as every value it computed was already on record or had no value (%s).", + "Report by %s ran successfully, but recorded nothing new, as every value it computed was already on record or had no value (%s).", source, summary, ) diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index 797318281f..aa8ccc96d4 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -22,8 +22,8 @@ class SchedulingJobResult: - Each constraint-type key holds a list of dicts, one per met slot (chronologically ordered): ``{"datetime": ISO 8601 UTC, "margin": "X kWh"}`` - ``outputs``: List of the sensors the schedule records on, one entry per sensor - Each entry is a dict with ``"sensor"`` field (sensor ID), ``"num-computed-beliefs"`` (the beliefs the scheduler computed for it), - and ``"num-saved-beliefs"`` (those saved, which leaves out beliefs that repeat what is already on record). - - On a dry run, nothing is saved, so each entry's ``"num-saved-beliefs"`` is 0. + and ``"num-recorded-beliefs"`` (those recorded, which leaves out beliefs that repeat what is already on record). + - On a dry run, nothing is recorded, so each entry's ``"num-recorded-beliefs"`` is 0. - ``num-beliefs``: Total number of beliefs (scheduled values) passed to the save, which is 0 on a dry run. **Important:** ``soc-targets`` (hard constraints) are never included since they are strictly enforced by the scheduler. @@ -50,7 +50,7 @@ class SchedulingJobResult: } ], "outputs": [ - {"sensor": 5, "num-computed-beliefs": 96, "num-saved-beliefs": 96}, + {"sensor": 5, "num-computed-beliefs": 96, "num-recorded-beliefs": 96}, ], "num-beliefs": 96 } diff --git a/flexmeasures/data/tests/test_plugin_automations_fresh_db.py b/flexmeasures/data/tests/test_plugin_automations_fresh_db.py index 2ccb10f87a..14f546c647 100644 --- a/flexmeasures/data/tests/test_plugin_automations_fresh_db.py +++ b/flexmeasures/data/tests/test_plugin_automations_fresh_db.py @@ -227,7 +227,7 @@ def test_plugin_job_reports_rows_saved( { "sensor": sensors[0].id, "num-computed-beliefs": 1, - "num-saved-beliefs": 1, + "num-recorded-beliefs": 1, } ] }, @@ -236,7 +236,7 @@ def test_plugin_job_reports_rows_saved( { "sensor": sensors[0].id, "num-computed-beliefs": 1, - "num-saved-beliefs": 0, + "num-recorded-beliefs": 0, } ] }, diff --git a/flexmeasures/data/tests/test_scheduling_jobs.py b/flexmeasures/data/tests/test_scheduling_jobs.py index 06b12e61c0..1236bfb780 100644 --- a/flexmeasures/data/tests/test_scheduling_jobs.py +++ b/flexmeasures/data/tests/test_scheduling_jobs.py @@ -475,7 +475,7 @@ def counts(result: dict) -> dict: return { output["sensor"]: ( output["num-computed-beliefs"], - output["num-saved-beliefs"], + output["num-recorded-beliefs"], ) for output in result["outputs"] } diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index f45fbea4b7..d4a1c75be7 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5341,7 +5341,7 @@ "/api/v3_0/jobs/{uuid}": { "get": { "summary": "Get the status of a background job", - "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe outputs field lists one entry per sensor the schedule records on:\nthe sensor (its ID), the number of beliefs (scheduled values) the scheduler computed for it (num-computed-beliefs),\nand the number saved to the database (num-saved-beliefs), which leaves out beliefs that were already on record.\nThe num-beliefs field holds the total number of beliefs passed to the save.\n\nFor a finished reporting job, result is an object holding the same outputs field, with one entry per output sensor of the report.\n", + "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe outputs field lists one entry per sensor the schedule records on:\nthe sensor (its ID), the number of beliefs (scheduled values) the scheduler computed for it (num-computed-beliefs),\nand the number recorded in the database (num-recorded-beliefs), which leaves out beliefs that were already on record.\nThe num-beliefs field holds the total number of beliefs passed to the save.\n\nFor a finished reporting job, result is an object holding the same outputs field, with one entry per output sensor of the report.\n", "security": [ { "ApiKeyAuth": [] @@ -5386,7 +5386,7 @@ "description": "Human-readable description of the job status." }, "result": { - "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). Its outputs field lists, per sensor the schedule records on, the sensor (its ID), num-computed-beliefs and num-saved-beliefs, and its num-beliefs field holds the total number of beliefs passed to the save. For a finished reporting job, this is an object holding the same outputs field.\n", + "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). Its outputs field lists, per sensor the schedule records on, the sensor (its ID), num-computed-beliefs and num-recorded-beliefs, and its num-beliefs field holds the total number of beliefs passed to the save. For a finished reporting job, this is an object holding the same outputs field.\n", "nullable": true }, "func-name": { @@ -5495,7 +5495,7 @@ { "sensor": 5, "num-computed-beliefs": 96, - "num-saved-beliefs": 96 + "num-recorded-beliefs": 96 } ], "num-beliefs": 96 From 749ce1ec6a458f86c3f3f4220493ea6627c9fd5d Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 17:57:36 +0100 Subject: [PATCH 09/11] Make a schedule's num-beliefs the total of the beliefs computed, also on a dry run num-beliefs is now always the total of num-computed-beliefs across the outputs. On a real run, that is what v1.0 counted, as it handed every computed belief to the save; only a CLI dry run, which creates no job, used to count 0. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- documentation/api/change_log.rst | 2 +- documentation/changelog.rst | 2 +- flexmeasures/api/v3_0/jobs.py | 4 ++-- flexmeasures/data/services/scheduling.py | 13 ++++++++----- flexmeasures/data/services/scheduling_result.py | 2 +- flexmeasures/data/tests/test_scheduling_jobs.py | 10 +++++----- flexmeasures/ui/static/openapi-specs.json | 4 ++-- 7 files changed, 20 insertions(+), 17 deletions(-) diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 752dcec5df..d077fc83f5 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -44,7 +44,7 @@ v3.0-34 | November XX, 2026 - ``GET /api/v3_0/assets//jobs`` now accepts ``page`` and ``per-page`` to return jobs newest first, with ``num-records`` and ``filtered-records`` counts. Paginated results can be sorted by enqueue time or queue with ``sort-by`` and ``sort-dir``. Requests without ``page`` retain the full list and previous response shape. - ``GET /api/v3_0/assets//jobs`` also lists the jobs of the assets below the asset, at any depth, as far as the caller may read them; pass ``include-child-assets=false`` to list only the asset's own jobs. Its entries include ``created_via`` provenance, and the automation that created a job only where the caller may read that automation. -- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed and how many it recorded, per output sensor. A reporting job's ``result`` is an object holding an ``outputs`` list, and a scheduling job's ``result`` gains the same ``outputs`` list, next to its unchanged ``num-beliefs``. Each entry holds the ``sensor`` (its ID), ``num-computed-beliefs`` and ``num-recorded-beliefs``. The recorded count leaves out beliefs that were already on record (and NaN values), so a rerun that only repeats what is on record records 0 of the beliefs it computed. +- ``GET /api/v3_0/jobs/`` reports, for a finished reporting or scheduling job, how many beliefs it computed and how many it recorded, per output sensor. A reporting job's ``result`` is an object holding an ``outputs`` list, and a scheduling job's ``result`` gains the same ``outputs`` list, next to its unchanged ``num-beliefs`` (the total of ``num-computed-beliefs`` across ``outputs``). Each entry holds the ``sensor`` (its ID), ``num-computed-beliefs`` and ``num-recorded-beliefs``. The recorded count leaves out beliefs that were already on record (and NaN values), so a rerun that only repeats what is on record records 0 of the beliefs it computed. - ``GET /api/v3_0/assets//jobs`` renames its ``redis_connection_err`` field, which shipped in v1.0.0, to ``redis-connection-err``, as the automation endpoints spell it. It is read by FlexMeasures' own UI rather than by the FlexMeasures client. - ``GET /api/v3_0/assets`` now scopes a listing the way its parameters say. ``num-records`` respects the ``root`` and ``depth`` constraints, where it used to count every asset in the account and asset-type scope. And an explicit ``include_public=false`` is now honoured, also next to ``all_accessible`` or ``root``; leaving the parameter out keeps the behaviour it had, where ``all_accessible`` and ``root`` include public assets and a listing of a single account does not. - ``GET /api/v3_0/sensors//status`` now accepts an optional ``asset_id`` query parameter, naming the asset whose status page the sensor is reported on. It only affects the reported ``relation``, which used to claim that the sensor belongs to the asset shown, also for a sensor that is only listed because the asset's flex-context or graphs page refers to it. Reading the status of a sensor in the context of an asset requires read permission on that asset, too. diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 534acc4226..592d6b35d6 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -191,7 +191,7 @@ Bugfixes * An ``AggregatorReporter`` input that holds more than one belief about an event, such as one read with ``most-recent-beliefs-only: false``, is now refused with a message naming the sensor and the event, where it used to fail with a ``KeyError`` [see `PR #2741 `_] * A ``PandasReporter`` with ``droplevels`` no longer fails with ``BeliefDataframe has more than one row per event`` when several data sources reported the same input event, as happens for a while when a job reporting on a rolling window gets a new data source; it keeps one belief per event, unless an input sets ``one_deterministic_belief_per_event`` to ``false`` [see `PR #2595 `_] -* The results of reporting and scheduling jobs now list, per output sensor, the beliefs computed and those recorded, so that a rerun which only repeats what is on record reads as having recorded nothing, rather than as having computed nothing; a schedule's ``num-beliefs`` is unchanged [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] +* The results of reporting and scheduling jobs now list, per output sensor, the beliefs computed and those recorded, so that a rerun which only repeats what is on record reads as having recorded nothing, rather than as having computed nothing; a schedule's ``num-beliefs`` still totals the beliefs computed, now also on a CLI dry run [see `PR #2689 `_, `PR #2571 `_ and `PR #2822 `_] * A forecaster registered by a plugin no longer needs to save its forecasts itself, but can return them instead: ``flexmeasures add forecasts`` now saves them, where it used to drop them while reporting them as created [see `PR #2689 `_ and `PR #2571 `_] * Running the same forecast twice from the same start no longer fails on the beliefs it already recorded [see `PR #2688 `_] * Whether a forecaster takes a regressor's belief as realized now follows the knowledge horizon of the regressor's sensor, as in timely-beliefs, so that the past readings of an instantaneous regressor (such as a thermometer read on the hour) and day-ahead prices (beliefs about future periods, which count as realized once the auction has cleared) are no longer kept from the model [see `PR #2692 `_] diff --git a/flexmeasures/api/v3_0/jobs.py b/flexmeasures/api/v3_0/jobs.py index e7c16a007a..ccf008e760 100644 --- a/flexmeasures/api/v3_0/jobs.py +++ b/flexmeasures/api/v3_0/jobs.py @@ -94,7 +94,7 @@ def get_job_status(self, job_id: str, **kwargs): The ``outputs`` field lists one entry per sensor the schedule records on: the ``sensor`` (its ID), the number of beliefs (scheduled values) the scheduler computed for it (``num-computed-beliefs``), and the number recorded in the database (``num-recorded-beliefs``), which leaves out beliefs that were already on record. - The ``num-beliefs`` field holds the total number of beliefs passed to the save. + The ``num-beliefs`` field holds the total of ``num-computed-beliefs`` across ``outputs``. For a finished reporting job, ``result`` is an object holding the same ``outputs`` field, with one entry per output sensor of the report. security: @@ -138,7 +138,7 @@ def get_job_status(self, job_id: str, **kwargs): (empty arrays when the flex model defines no ``soc-minima``/``soc-maxima``, or when a scheduler other than ``StorageScheduler`` was used). Its ``outputs`` field lists, per sensor the schedule records on, the ``sensor`` (its ID), ``num-computed-beliefs`` and ``num-recorded-beliefs``, - and its ``num-beliefs`` field holds the total number of beliefs passed to the save. + and its ``num-beliefs`` field holds the total of ``num-computed-beliefs`` across ``outputs``. For a finished reporting job, this is an object holding the same ``outputs`` field. nullable: true func-name: diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 38898f6638..c9408fe3d3 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -973,8 +973,8 @@ def make_schedule( # noqa: C901 ) -> dict: """ This function computes a schedule. It returns a dict (see ``SchedulingJobResult``), - which lists, per output sensor, the beliefs the schedule computed and those it saved (under ``outputs``), - and counts the beliefs passed to the save (``num-beliefs``). + which lists, per output sensor, the beliefs the schedule computed and those it recorded (under ``outputs``), + and the total of the beliefs computed (``num-beliefs``). If the scheduler produced soft state-of-charge constraint analysis, the dict also holds that analysis under ``unresolved`` and ``resolved`` keys, which are empty lists otherwise. @@ -1168,9 +1168,12 @@ def make_schedule( # noqa: C901 # which leaves out beliefs that repeat what is already on record. # Each output also counts the beliefs computed, so that saving none does not read as computing none. saved = save_generator_results(to_save) if to_save else [] - scheduling_result_dict["outputs"] = outputs_of_job_result(saved + dry_run_outputs) - # num-beliefs counts the beliefs passed to the save, as it has since v1.0, which are none on a dry run. - scheduling_result_dict["num-beliefs"] = sum(entry["n_computed"] for entry in saved) + outputs = outputs_of_job_result(saved + dry_run_outputs) + scheduling_result_dict["outputs"] = outputs + # num-beliefs is the total of the beliefs computed, also on a dry run. + scheduling_result_dict["num-beliefs"] = sum( + output["num-computed-beliefs"] for output in outputs + ) if not dry_run: scheduler.persist_flex_model() diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index aa8ccc96d4..168ccb0467 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -24,7 +24,7 @@ class SchedulingJobResult: - Each entry is a dict with ``"sensor"`` field (sensor ID), ``"num-computed-beliefs"`` (the beliefs the scheduler computed for it), and ``"num-recorded-beliefs"`` (those recorded, which leaves out beliefs that repeat what is already on record). - On a dry run, nothing is recorded, so each entry's ``"num-recorded-beliefs"`` is 0. - - ``num-beliefs``: Total number of beliefs (scheduled values) passed to the save, which is 0 on a dry run. + - ``num-beliefs``: Total number of beliefs (scheduled values) computed, i.e. the total of ``"num-computed-beliefs"`` across ``outputs``. **Important:** ``soc-targets`` (hard constraints) are never included since they are strictly enforced by the scheduler. Only hard constraint failures cause job failure. diff --git a/flexmeasures/data/tests/test_scheduling_jobs.py b/flexmeasures/data/tests/test_scheduling_jobs.py index 1236bfb780..ad28d906f8 100644 --- a/flexmeasures/data/tests/test_scheduling_jobs.py +++ b/flexmeasures/data/tests/test_scheduling_jobs.py @@ -429,12 +429,12 @@ def test_save_state_of_charge( def test_schedule_result_lists_per_output_what_was_computed_and_saved( fresh_db, app, smart_building ): - """A schedule's result lists, per output sensor, the beliefs computed and those saved, and num-beliefs counts those passed to the save. + """A schedule's result lists, per output sensor, the beliefs computed and those recorded, and num-beliefs totals those computed. A heat buffer with a state-of-charge sensor records on two sensors: its power and its state of charge. - A dry run saves none of what it computes, a first run saves all of it, - and a rerun computes the same schedule, which is already on record, so it saves none of it. - num-beliefs counts the beliefs passed to the save, as it did in v1.0: none on a dry run, and all of them on a rerun. + A dry run records none of what it computes, a first run records all of it, + and a rerun computes the same schedule, which is already on record, so it records none of it. + num-beliefs is the total of the beliefs computed, on each of these runs. """ assets, sensors, soc_sensors = smart_building power_sensor = sensors["Test Heat Buffer"] @@ -488,7 +488,7 @@ def counts(result: dict) -> dict: assert counts(rerun) == {sensor: (n, 0) for sensor, n in computed.items()} total = sum(computed.values()) assert [dry_run["num-beliefs"], first["num-beliefs"], rerun["num-beliefs"]] == [ - 0, + total, total, total, ] diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index d4a1c75be7..69e2ee7562 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5341,7 +5341,7 @@ "/api/v3_0/jobs/{uuid}": { "get": { "summary": "Get the status of a background job", - "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe outputs field lists one entry per sensor the schedule records on:\nthe sensor (its ID), the number of beliefs (scheduled values) the scheduler computed for it (num-computed-beliefs),\nand the number recorded in the database (num-recorded-beliefs), which leaves out beliefs that were already on record.\nThe num-beliefs field holds the total number of beliefs passed to the save.\n\nFor a finished reporting job, result is an object holding the same outputs field, with one entry per output sensor of the report.\n", + "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe outputs field lists one entry per sensor the schedule records on:\nthe sensor (its ID), the number of beliefs (scheduled values) the scheduler computed for it (num-computed-beliefs),\nand the number recorded in the database (num-recorded-beliefs), which leaves out beliefs that were already on record.\nThe num-beliefs field holds the total of num-computed-beliefs across outputs.\n\nFor a finished reporting job, result is an object holding the same outputs field, with one entry per output sensor of the report.\n", "security": [ { "ApiKeyAuth": [] @@ -5386,7 +5386,7 @@ "description": "Human-readable description of the job status." }, "result": { - "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). Its outputs field lists, per sensor the schedule records on, the sensor (its ID), num-computed-beliefs and num-recorded-beliefs, and its num-beliefs field holds the total number of beliefs passed to the save. For a finished reporting job, this is an object holding the same outputs field.\n", + "description": "Return value of the job function, or null when not yet available. For a finished scheduling job, this is an object; a StorageScheduler job populates it with unresolved/resolved soft state-of-charge constraint analysis (empty arrays when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used). Its outputs field lists, per sensor the schedule records on, the sensor (its ID), num-computed-beliefs and num-recorded-beliefs, and its num-beliefs field holds the total of num-computed-beliefs across outputs. For a finished reporting job, this is an object holding the same outputs field.\n", "nullable": true }, "func-name": { From 7757b28fee54b7c49fbabbcbe754cfd17c2af8ab Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 17:57:56 +0100 Subject: [PATCH 10/11] Use one summary line in add schedule: computed N, recorded M, and why add schedule ends with "Scheduling done: computed N, recorded M", followed by ", as this was a dry run" or ", as the rest was already on record" where fewer were recorded than computed. The per-sensor print on a dry run says it is not recording the schedule, rather than not saving it. Refs #2821 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/cli/data_add.py | 25 ++++++------------- .../cli/tests/test_data_add_fresh_db.py | 24 +++++++++++------- flexmeasures/data/services/scheduling.py | 2 +- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/flexmeasures/cli/data_add.py b/flexmeasures/cli/data_add.py index 752a2f307d..1b5808fe5e 100755 --- a/flexmeasures/cli/data_add.py +++ b/flexmeasures/cli/data_add.py @@ -2210,29 +2210,20 @@ def add_schedule( # noqa C901 n_recorded = sum( output["num-recorded-beliefs"] for output in scheduling_result["outputs"] ) - if dry_run: - click.secho( - f"The schedule above was computed but not stored (because of --dry-run): computed {n_computed}, recorded 0.", - **MsgStyle.SUCCESS, - ) - elif n_computed == 0: + if n_computed == 0: click.secho( "The scheduler computed no beliefs, so no new schedule was stored.", **MsgStyle.WARN, ) - elif n_recorded == n_computed: - click.secho( - f"New schedule is stored: recorded {pluralize('belief', n_recorded, include_count=True)}.", - **MsgStyle.SUCCESS, - ) - elif n_recorded: - click.secho( - f"New schedule is stored: computed {n_computed}, recorded {n_recorded}, as the rest was already on record.", - **MsgStyle.SUCCESS, - ) else: + if dry_run: + reason = ", as this was a dry run" + elif n_recorded < n_computed: + reason = ", as the rest was already on record" + else: + reason = "" click.secho( - f"The new schedule repeats beliefs already on record, so nothing new was stored (computed {n_computed}, recorded 0).", + f"Scheduling done: computed {n_computed}, recorded {n_recorded}{reason}.", **MsgStyle.SUCCESS, ) diff --git a/flexmeasures/cli/tests/test_data_add_fresh_db.py b/flexmeasures/cli/tests/test_data_add_fresh_db.py index 726dc9f958..2c2f605641 100644 --- a/flexmeasures/cli/tests/test_data_add_fresh_db.py +++ b/flexmeasures/cli/tests/test_data_add_fresh_db.py @@ -1494,7 +1494,7 @@ def test_add_process( # ProcessScheduler's make_schedule() call returns a dict (not the boolean # True), which used to be falsy enough to silently suppress this message; # confirm the message still appears. - assert "New schedule is stored" in result.output + assert "Scheduling done: computed" in result.output process_power_sensor = db.session.get(Sensor, process_power_sensor_id) schedule = process_power_sensor.search_beliefs() @@ -1529,27 +1529,33 @@ def test_add_schedule_dry_run_saves_no_beliefs( result = runner.invoke(add_schedule, cli_input + ["--dry-run"]) check_command_ran_without_error(result) - assert "Not saving schedule for sensor `power`" in result.output + assert "Not recording schedule for sensor `power`" in result.output assert "covering events from" in result.output - assert "computed but not stored (because of --dry-run)" in result.output - assert "New schedule is stored" not in result.output sensor = fresh_db.session.get(Sensor, process_power_sensor) assert len(sensor.search_beliefs()) == 0 - # A dry run still says how much it computed, which a normal run then saves in full. - n_computed = int(re.search(r"computed (\d+), recorded 0", result.output).group(1)) + # A dry run still says how much it computed, which a normal run then records in full. + n_computed = int( + re.search( + r"Scheduling done: computed (\d+), recorded 0, as this was a dry run\.", + result.output, + ).group(1) + ) assert n_computed > 0 # A normal run does record the schedule, so the dry run really skipped that step result = runner.invoke(add_schedule, cli_input) check_command_ran_without_error(result) - assert f"New schedule is stored: recorded {n_computed} beliefs." in result.output + assert ( + f"Scheduling done: computed {n_computed}, recorded {n_computed}.\n" + in result.output + ) assert len(sensor.search_beliefs()) == n_computed # Running it again computes the same schedule, which is already on record, so nothing new is saved. result = runner.invoke(add_schedule, cli_input) check_command_ran_without_error(result) assert ( - f"repeats beliefs already on record, so nothing new was stored (computed {n_computed}, recorded 0)" + f"Scheduling done: computed {n_computed}, recorded 0, as the rest was already on record." in result.output ) assert len(sensor.search_beliefs()) == n_computed @@ -1575,7 +1581,7 @@ def test_add_schedule_says_when_the_scheduler_computed_nothing( "The scheduler computed no beliefs, so no new schedule was stored." in result.output ) - assert "New schedule is stored" not in result.output + assert "Scheduling done" not in result.output def test_add_schedule_rejects_dry_run_as_job(app, fresh_db, add_market_prices_fresh_db): diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index c9408fe3d3..cabc9e16c7 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -1160,7 +1160,7 @@ def make_schedule( # noqa: C901 else "" ) print( - f"\nNot saving schedule for sensor `{bdf.sensor}` (ID {bdf.sensor.id}) to the database (because of --dry-run)," + f"\nNot recording schedule for sensor `{bdf.sensor}` (ID {bdf.sensor.id}) in the database (because of --dry-run)," f" but this is what I computed ({len(bdf)} beliefs{event_range}):\n{bdf}" ) From 367bf2b46a7f60b897ea2ebcf3dbcfa372dad513 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Wed, 4 Nov 2026 18:12:44 +0100 Subject: [PATCH 11/11] Say that the recorded count also leaves out beliefs without a value Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UBBvKZkzbfEPKctwTN8SDf Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/jobs.py | 2 +- flexmeasures/data/services/scheduling_result.py | 2 +- flexmeasures/ui/static/openapi-specs.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/api/v3_0/jobs.py b/flexmeasures/api/v3_0/jobs.py index ccf008e760..40e8e84946 100644 --- a/flexmeasures/api/v3_0/jobs.py +++ b/flexmeasures/api/v3_0/jobs.py @@ -93,7 +93,7 @@ def get_job_status(self, job_id: str, **kwargs): the sensor schedule endpoint (``GET /api/v3_0/sensors//schedules/``) returns power values only. The ``outputs`` field lists one entry per sensor the schedule records on: the ``sensor`` (its ID), the number of beliefs (scheduled values) the scheduler computed for it (``num-computed-beliefs``), - and the number recorded in the database (``num-recorded-beliefs``), which leaves out beliefs that were already on record. + and the number recorded in the database (``num-recorded-beliefs``), which leaves out beliefs that were already on record or had no value. The ``num-beliefs`` field holds the total of ``num-computed-beliefs`` across ``outputs``. For a finished reporting job, ``result`` is an object holding the same ``outputs`` field, with one entry per output sensor of the report. diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index 168ccb0467..75af51c9da 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -22,7 +22,7 @@ class SchedulingJobResult: - Each constraint-type key holds a list of dicts, one per met slot (chronologically ordered): ``{"datetime": ISO 8601 UTC, "margin": "X kWh"}`` - ``outputs``: List of the sensors the schedule records on, one entry per sensor - Each entry is a dict with ``"sensor"`` field (sensor ID), ``"num-computed-beliefs"`` (the beliefs the scheduler computed for it), - and ``"num-recorded-beliefs"`` (those recorded, which leaves out beliefs that repeat what is already on record). + and ``"num-recorded-beliefs"`` (those recorded, which leaves out beliefs that repeat what is already on record or have no value). - On a dry run, nothing is recorded, so each entry's ``"num-recorded-beliefs"`` is 0. - ``num-beliefs``: Total number of beliefs (scheduled values) computed, i.e. the total of ``"num-computed-beliefs"`` across ``outputs``. diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 69e2ee7562..751538e77c 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5341,7 +5341,7 @@ "/api/v3_0/jobs/{uuid}": { "get": { "summary": "Get the status of a background job", - "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe outputs field lists one entry per sensor the schedule records on:\nthe sensor (its ID), the number of beliefs (scheduled values) the scheduler computed for it (num-computed-beliefs),\nand the number recorded in the database (num-recorded-beliefs), which leaves out beliefs that were already on record.\nThe num-beliefs field holds the total of num-computed-beliefs across outputs.\n\nFor a finished reporting job, result is an object holding the same outputs field, with one entry per output sensor of the report.\n", + "description": "Look up a background job by its UUID and see whether it is queued, running, finished, or failed.\n\nThe response includes a status message plus job metadata,\nsuch as the queue name, function name, timestamps, and the job result when available.\n\nFailed jobs also include traceback information when the worker stored it with the job result.\n\nFor a finished scheduling job, result is an object.\nFor a StorageScheduler job, it holds soft state-of-charge constraint analysis:\nunresolved lists constraints the scheduler could not satisfy,\nand resolved lists constraints that were satisfied with some margin.\nEach device entry's soc-minima/soc-maxima value is a list,\nholding one entry per violated slot (for unresolved) or per met slot with its margin (for resolved), ordered chronologically.\nBoth arrays are empty when the flex model defines no soc-minima/soc-maxima, or when a scheduler other than StorageScheduler was used.\nThis is the only place constraint analysis is available:\nthe sensor schedule endpoint (GET /api/v3_0/sensors//schedules/) returns power values only.\nThe outputs field lists one entry per sensor the schedule records on:\nthe sensor (its ID), the number of beliefs (scheduled values) the scheduler computed for it (num-computed-beliefs),\nand the number recorded in the database (num-recorded-beliefs), which leaves out beliefs that were already on record or had no value.\nThe num-beliefs field holds the total of num-computed-beliefs across outputs.\n\nFor a finished reporting job, result is an object holding the same outputs field, with one entry per output sensor of the report.\n", "security": [ { "ApiKeyAuth": []