From 7d85db0188bbc76352d2751ec11afe70e4bd000d Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:16:37 +0100 Subject: [PATCH 001/141] feat: make predict retrain-frequency default to planning horizon then min(planning_horizon, data["max_forecast_horizon"]) Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/forecasting/pipeline.py | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 1b66a4173a..1682a02055 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -400,32 +400,17 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 f"train-period is greater than max-training-period ({max_training_period}), setting train-period to max-training-period", ) - if data.get("retrain_frequency") is None and data.get("end_date") is not None: - retrain_frequency_in_hours = int( - (data["end_date"] - predict_start).total_seconds() / 3600 - ) - elif ( - data.get("retrain_frequency") is None - and data.get("end_date") is None - and data.get("max_forecast_horizon") is not None - ): - retrain_frequency_in_hours = data.get("max_forecast_horizon") // timedelta( - hours=1 - ) - elif ( - data.get("retrain_frequency") is None - and data.get("end_date") is None - and data.get("max_forecast_horizon") is None - ): - retrain_frequency_in_hours = current_app.config.get( - "FLEXMEASURES_PLANNING_HORIZON" - ) // timedelta( - hours=1 - ) # Set default retrain_frequency to planning horizon + if data.get("retrain_frequency") is None: + if data.get("max_forecast_horizon") is None: + predict_period = planning_horizon + else: + predict_period = min(planning_horizon, data["max_forecast_horizon"]) else: - retrain_frequency_in_hours = data["retrain_frequency"] // timedelta(hours=1) - if retrain_frequency_in_hours < 1: - raise ValidationError("retrain-frequency must be at least 1 hour") + predict_period = data["retrain_frequency"] + + retrain_frequency_in_hours = predict_period // timedelta(hours=1) + if retrain_frequency_in_hours < 1: + raise ValidationError("retrain-frequency must be at least 1 hour") if data.get("end_date") is None: data["end_date"] = predict_start + timedelta( From 39e64ec242480901c1140503bb8f0d2c3121116f Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:17:43 +0100 Subject: [PATCH 002/141] refactor: simplify end_date calculation to use predict_period instead of timedelta Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 1682a02055..890df9915b 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -413,9 +413,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 raise ValidationError("retrain-frequency must be at least 1 hour") if data.get("end_date") is None: - data["end_date"] = predict_start + timedelta( - hours=retrain_frequency_in_hours - ) + data["end_date"] = predict_start + predict_period if data.get("start_date") is None: start_date = predict_start - timedelta(hours=train_period_in_hours) From 9d83911a11dc39f5bf9389e183dda0410961951b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:18:27 +0100 Subject: [PATCH 003/141] feat: default max_forecast_horizon to predict_period Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 890df9915b..9f73961984 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -423,13 +423,8 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 max_forecast_horizon = data.get("max_forecast_horizon") forecast_frequency = data.get("forecast_frequency") - if max_forecast_horizon is None and forecast_frequency is None: - max_forecast_horizon = timedelta(hours=retrain_frequency_in_hours) - forecast_frequency = timedelta(hours=retrain_frequency_in_hours) - elif max_forecast_horizon is None: - max_forecast_horizon = forecast_frequency - elif forecast_frequency is None: - forecast_frequency = max_forecast_horizon + if max_forecast_horizon is None: + max_forecast_horizon = predict_period if data.get("sensor_to_save") is None: sensor_to_save = target_sensor From 7543395a7d15d64fd2c432dcb99aa0f861f60651 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:18:47 +0100 Subject: [PATCH 004/141] feat: add validation for max_forecast_horizon to ensure it does not exceed predict_period Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 9f73961984..68f28a90bd 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -426,6 +426,11 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 if max_forecast_horizon is None: max_forecast_horizon = predict_period + if max_forecast_horizon > predict_period: + raise ValidationError( + "max-forecast-horizon must be less than or equal to predict-period", + field_name="max_forecast_horizon", + ) if data.get("sensor_to_save") is None: sensor_to_save = target_sensor else: From da4b943067e6f6fcfc2b44085d27f64491d9f8ab Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:18:55 +0100 Subject: [PATCH 005/141] feat: set default forecast_frequency based on min of planning_horizon, predict_period, and max_forecast_horizon Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 68f28a90bd..9fe0f633d8 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -431,6 +431,15 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 "max-forecast-horizon must be less than or equal to predict-period", field_name="max_forecast_horizon", ) + + if forecast_frequency is None: + forecast_frequency = min( + planning_horizon, + predict_period, + max_forecast_horizon, + timedelta(hours=retrain_frequency_in_hours), + ) + if data.get("sensor_to_save") is None: sensor_to_save = target_sensor else: From 43d8d9dbb1d296142987e23d157a8f74456b3eb7 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:20:23 +0100 Subject: [PATCH 006/141] fix: add planning horizon from config Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 9fe0f633d8..cc4862a278 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -360,6 +360,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 target_sensor = data["sensor"] resolution = target_sensor.event_resolution + planning_horizon = current_app.config.get("FLEXMEASURES_PLANNING_HORIZON") now = server_now() floored_now = floor_to_resolution(now, resolution) From b3dd12d3217f1997554c0ac2207f482eab396763 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:21:43 +0100 Subject: [PATCH 007/141] dev: uncomment out tests that were failing Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/tests/test_forecasting.py | 165 +++++++++--------- 1 file changed, 83 insertions(+), 82 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 74f277b55b..831774bd51 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -139,31 +139,31 @@ # - max-forecast-horizon remains at planning horizon (48 hours) # - 1 cycle, 4 belief times # this fails - # ( - # {"forecast-frequency": "PT12H"}, - # { - # "predict_start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h"), - # "start_date": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h") - # - pd.Timedelta(days=30), - # "train_period_in_hours": 720, - # "predict_period_in_hours": 48, - # "max_forecast_horizon": pd.Timedelta(hours=12), - # "forecast_frequency": pd.Timedelta(hours=12), - # "end_date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # + pd.Timedelta(hours=48), - # "max_training_period": pd.Timedelta(days=365), - # "save_belief_time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ), - # "n_cycles": 1, - # }, - # ), + ( + {"forecast-frequency": "PT12H"}, + { + "predict_start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h"), + "start_date": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h") + - pd.Timedelta(days=30), + "train_period_in_hours": 720, + "predict_period_in_hours": 48, + "max_forecast_horizon": pd.Timedelta(hours=48), + "forecast_frequency": pd.Timedelta(hours=48), + "end_date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + + pd.Timedelta(hours=48), + "max_training_period": pd.Timedelta(days=365), + "save_belief_time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ), + "n_cycles": 1, + }, + ), ### # Case 4 user expectation: # - Default planning horizon predictions, retraining every 12 hours @@ -203,65 +203,65 @@ # - forecast-frequency = 12 hours # - 5 cycles, 20 belief times # this fails - # ( - # { - # "retrain-frequency": "P10D", - # "max-forecast-horizon": "PT12H", - # }, - # { - # "predict_start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h"), - # "start_date": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h") - # - pd.Timedelta(days=30), - # "train_period_in_hours": 720, - # "predict_period_in_hours": 240, - # "max_forecast_horizon": pd.Timedelta(hours=12), - # "forecast_frequency": pd.Timedelta(hours=12), - # "end_date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # + pd.Timedelta(days=10), - # "max_training_period": pd.Timedelta(days=365), - # "save_belief_time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ), - # "n_cycles": 5, - # }, - # ), + ( + { + "retrain-frequency": "P10D", + "max-forecast-horizon": "PT12H", + }, + { + "predict_start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h"), + "start_date": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h") + - pd.Timedelta(days=30), + "train_period_in_hours": 720, + "predict_period_in_hours": 240, + "max_forecast_horizon": pd.Timedelta(hours=12), + "forecast_frequency": pd.Timedelta(hours=12), + "end_date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + + pd.Timedelta(days=10), + "max_training_period": pd.Timedelta(days=365), + "save_belief_time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ), + "n_cycles": 1, + }, + ), # Case 6 user expectation: # - FM should complain: max-forecast-horizon must be <= predict-period - # this fails - # ( - # { - # "retrain-frequency": "PT12H", - # "max-forecast-horizon": "P10D", - # }, - # { - # "predict_start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h"), - # "start_date": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h") - # - pd.Timedelta(days=30), - # "train_period_in_hours": 720, - # "predict_period_in_hours": 12, - # "max_forecast_horizon": pd.Timedelta(days=10), - # "forecast_frequency": pd.Timedelta(days=10), - # "end_date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # + pd.Timedelta(hours=12), - # "max_training_period": pd.Timedelta(days=365), - # "save_belief_time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ), - # "n_cycles": 1, - # }, - # ), + # this should fails it expects a validation error + ( + { + "retrain-frequency": "PT12H", + "max-forecast-horizon": "P10D", + }, + { + "predict_start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h"), + "start_date": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h") + - pd.Timedelta(days=30), + "train_period_in_hours": 720, + "predict_period_in_hours": 12, + "max_forecast_horizon": pd.Timedelta(days=10), + "forecast_frequency": pd.Timedelta(days=10), + "end_date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + + pd.Timedelta(hours=12), + "max_training_period": pd.Timedelta(days=365), + "save_belief_time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ), + "n_cycles": 1, + }, + ), ### # We expect training period of 30 days before predict start and prediction period of 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief time for forecast) given the forecast frequency equal defaulted to prediction period of 48 hours. @@ -567,6 +567,7 @@ def test_timing_parameters_of_forecaster_parameters_schema( } ) + breakpoint() for k, v in expected_timing_output.items(): # Convert kebab-case key to snake_case to match data dictionary keys returned by schema snake_key = kebab_to_snake(k) From 24f1569641daac923af90b12e83dcf5895ff5d8b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:49:34 +0100 Subject: [PATCH 008/141] fix: fix test cae forecast_frequency expectation it should be 12 hours not 48hours since we want New forecast viewpoint every 12 hours Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/tests/test_forecasting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 831774bd51..c8b4d98222 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -152,7 +152,7 @@ "train_period_in_hours": 720, "predict_period_in_hours": 48, "max_forecast_horizon": pd.Timedelta(hours=48), - "forecast_frequency": pd.Timedelta(hours=48), + "forecast_frequency": pd.Timedelta(hours=12), "end_date": pd.Timestamp( "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ) From 9c5d677552c2aa42650a1b10ea81b118906bced6 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 09:52:06 +0100 Subject: [PATCH 009/141] fix: tests should expect 5 cycles. the test passes when we expect 1cycle Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/tests/test_forecasting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index c8b4d98222..d7a00ffaa3 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -228,7 +228,7 @@ "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ), - "n_cycles": 1, + "n_cycles": 5, }, ), # Case 6 user expectation: From 3642c9af9711234ac514e996625692497b7270d8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 10:51:51 +0100 Subject: [PATCH 010/141] feat: add duration to schema Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index cc4862a278..e73f9a3be1 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -17,7 +17,11 @@ ) from flexmeasures.data.schemas import SensorIdField -from flexmeasures.data.schemas.times import AwareDateTimeOrDateField, DurationField +from flexmeasures.data.schemas.times import ( + AwareDateTimeOrDateField, + DurationField, + PlanningDurationField, +) from flexmeasures.data.models.forecasting.utils import floor_to_resolution from flexmeasures.utils.time_utils import server_now @@ -168,6 +172,13 @@ class ForecasterParametersSchema(Schema): }, }, ) + duration = PlanningDurationField( + load_default=PlanningDurationField.load_default, + metadata=dict( + description="The duration for which to create the forecast, also known as the planning horizon, in ISO 8601 duration format.", + example="PT24H", + ), + ) end_date = AwareDateTimeOrDateField( data_key="end-date", required=False, From a448bf5026bda5275f57d027f10ef8a1393d75f9 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 11:07:59 +0100 Subject: [PATCH 011/141] feat: pass original data to `resolve_config` so we can check whether start, end and duration have been passed Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index e73f9a3be1..e7c0ef2b43 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -365,8 +365,8 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 field_name="max_training_period", ) - @post_load - def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 + @post_load(pass_original=True) + def resolve_config(self, data: dict, original_data, **kwargs) -> dict: # noqa: C901 target_sensor = data["sensor"] From 3456ffdcc8f047a2fadb0595fe366a41f5fe6c81 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:05:17 +0100 Subject: [PATCH 012/141] feat: move end_date calculation up Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/forecasting/pipeline.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index e73f9a3be1..9cc6dca35b 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -411,7 +411,19 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 logging.warning( f"train-period is greater than max-training-period ({max_training_period}), setting train-period to max-training-period", ) + if data.get("end_date") is None: + data["end_date"] = predict_start + data['duration'] + if data.get("start_date") is None: + start_date = predict_start - timedelta(hours=train_period_in_hours) + else: + start_date = data["start_date"] + + if data.get("end_date") is not None and data.get("duration"): # check if duration has been given not check it's default value + raise ValidationError( + "end-date and duration cannot both be set. Please provide only one of these parameters.", + field_name="end_date", + ) if data.get("retrain_frequency") is None: if data.get("max_forecast_horizon") is None: predict_period = planning_horizon @@ -424,13 +436,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 if retrain_frequency_in_hours < 1: raise ValidationError("retrain-frequency must be at least 1 hour") - if data.get("end_date") is None: - data["end_date"] = predict_start + predict_period - if data.get("start_date") is None: - start_date = predict_start - timedelta(hours=train_period_in_hours) - else: - start_date = data["start_date"] max_forecast_horizon = data.get("max_forecast_horizon") forecast_frequency = data.get("forecast_frequency") From a3c63a42503196fd3163ff8be39204db76e56fbb Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:07:50 +0100 Subject: [PATCH 013/141] feat: fix max-forecast-horizon and forecast freq default calculation Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/forecasting/pipeline.py | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 9cc6dca35b..bb1f53b626 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -424,22 +424,10 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 "end-date and duration cannot both be set. Please provide only one of these parameters.", field_name="end_date", ) - if data.get("retrain_frequency") is None: - if data.get("max_forecast_horizon") is None: - predict_period = planning_horizon - else: - predict_period = min(planning_horizon, data["max_forecast_horizon"]) - else: - predict_period = data["retrain_frequency"] - - retrain_frequency_in_hours = predict_period // timedelta(hours=1) - if retrain_frequency_in_hours < 1: - raise ValidationError("retrain-frequency must be at least 1 hour") - - + predict_period = data["end_date"] - predict_start if data.get("end_date") else data["duration"] + forecast_frequency = data.get("forecast_frequency") max_forecast_horizon = data.get("max_forecast_horizon") - forecast_frequency = data.get("forecast_frequency") if max_forecast_horizon is None: max_forecast_horizon = predict_period @@ -453,11 +441,22 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 if forecast_frequency is None: forecast_frequency = min( planning_horizon, - predict_period, max_forecast_horizon, - timedelta(hours=retrain_frequency_in_hours), + predict_period, ) + if data.get("retrain_frequency") is None: + if data.get("max_forecast_horizon") is None: + predict_period = planning_horizon + else: + predict_period = min(planning_horizon, data["max_forecast_horizon"]) # this is the iss + else: + predict_period = data["retrain_frequency"] + + retrain_frequency_in_hours = predict_period // timedelta(hours=1) + if retrain_frequency_in_hours < 1: + raise ValidationError("retrain-frequency must be at least 1 hour") + if data.get("sensor_to_save") is None: sensor_to_save = target_sensor else: @@ -471,7 +470,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 if model_save_dir is None: # Read default from schema model_save_dir = self.fields["model_save_dir"].load_default - + breakpoint() return dict( target=target_sensor, model_save_dir=model_save_dir, From 2c6e846c26e9b6940cd0345dee34a28d6b4e08ec Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:13:43 +0100 Subject: [PATCH 014/141] dev: remove breakpoint Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index bb1f53b626..902e4f5e0b 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -470,7 +470,6 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 if model_save_dir is None: # Read default from schema model_save_dir = self.fields["model_save_dir"].load_default - breakpoint() return dict( target=target_sensor, model_save_dir=model_save_dir, From b4d261c42ef8fb0b804b32c5fdb8d3297c626eb8 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 11:16:22 +0100 Subject: [PATCH 015/141] feat: throw ValidationError in case start, end and duration are all passed Signed-off-by: F.N. Claessen --- .../data/schemas/forecasting/pipeline.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index de3d701414..d498c8c35e 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -368,6 +368,12 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 @post_load(pass_original=True) def resolve_config(self, data: dict, original_data, **kwargs) -> dict: # noqa: C901 + if {"start", "end", "duration"} & original_data.keys(): + raise ValidationError( + "Provide 'duration' with either 'start' or 'end', but not with both.", + field_name="duration", + ) + target_sensor = data["sensor"] resolution = target_sensor.event_resolution @@ -412,19 +418,25 @@ def resolve_config(self, data: dict, original_data, **kwargs) -> dict: # noqa: f"train-period is greater than max-training-period ({max_training_period}), setting train-period to max-training-period", ) if data.get("end_date") is None: - data["end_date"] = predict_start + data['duration'] + data["end_date"] = predict_start + data["duration"] if data.get("start_date") is None: start_date = predict_start - timedelta(hours=train_period_in_hours) else: start_date = data["start_date"] - if data.get("end_date") is not None and data.get("duration"): # check if duration has been given not check it's default value + if data.get("end_date") is not None and data.get( + "duration" + ): # check if duration has been given not check it's default value raise ValidationError( "end-date and duration cannot both be set. Please provide only one of these parameters.", field_name="end_date", ) - predict_period = data["end_date"] - predict_start if data.get("end_date") else data["duration"] + predict_period = ( + data["end_date"] - predict_start + if data.get("end_date") + else data["duration"] + ) forecast_frequency = data.get("forecast_frequency") max_forecast_horizon = data.get("max_forecast_horizon") @@ -449,7 +461,9 @@ def resolve_config(self, data: dict, original_data, **kwargs) -> dict: # noqa: if data.get("max_forecast_horizon") is None: predict_period = planning_horizon else: - predict_period = min(planning_horizon, data["max_forecast_horizon"]) # this is the iss + predict_period = min( + planning_horizon, data["max_forecast_horizon"] + ) # this is the iss else: predict_period = data["retrain_frequency"] From 32e06af2ff4d9a16a5f02f8286214ce2e0cdbc82 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:19:46 +0100 Subject: [PATCH 016/141] fix: remove unneeded validation Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 13 +------------ flexmeasures/data/schemas/tests/test_forecasting.py | 8 +++++--- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 5129db2882..1bd690525a 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -425,18 +425,7 @@ def resolve_config(self, data: dict, original_data, **kwargs) -> dict: # noqa: else: start_date = data["start_date"] - if data.get("end_date") is not None and data.get( - "duration" - ): # check if duration has been given not check it's default value - raise ValidationError( - "end-date and duration cannot both be set. Please provide only one of these parameters.", - field_name="end_date", - ) - predict_period = ( - data["end_date"] - predict_start - if data.get("end_date") - else data["duration"] - ) + predict_period = data["end_date"] - predict_start if data.get("end_date") else data["duration"] forecast_frequency = data.get("forecast_frequency") max_forecast_horizon = data.get("max_forecast_horizon") diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index d7a00ffaa3..b807b1aec9 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -68,10 +68,12 @@ # Timing parameter defaults # - predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) # - max-forecast-horizon defaults to the predict-period - # - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon and retraining-frequency) - # - retraining-frequency defaults to FM planning horizon + # - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) + # - retraining-frequency defaults to maximum of (FM planning horizon and forecast-frequency) so at this point we need forecast-frequency calculated + # Timing parameter constraints - # - max-forecast-horizon <= predict-period + # - max-forecast-horizon <= predict-period, raise validation error if not respected + # - if retrain_freq <= forecast-frequency, enforce retrain_freq = forecast-frequency don't crash # Case 1 user expectation: # - Get forecasts for next 12 hours from a single viewpoint # - max-forecast-horizon = 12 hours From f3c31abc2c22cf74f066970873cd750ff46f4c90 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:22:12 +0100 Subject: [PATCH 017/141] style: run pre-commit Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 6 +++++- flexmeasures/data/schemas/tests/test_forecasting.py | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 1bd690525a..54d7497e5f 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -425,7 +425,11 @@ def resolve_config(self, data: dict, original_data, **kwargs) -> dict: # noqa: else: start_date = data["start_date"] - predict_period = data["end_date"] - predict_start if data.get("end_date") else data["duration"] + predict_period = ( + data["end_date"] - predict_start + if data.get("end_date") + else data["duration"] + ) forecast_frequency = data.get("forecast_frequency") max_forecast_horizon = data.get("max_forecast_horizon") diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index b807b1aec9..80029d54af 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -70,7 +70,6 @@ # - max-forecast-horizon defaults to the predict-period # - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) # - retraining-frequency defaults to maximum of (FM planning horizon and forecast-frequency) so at this point we need forecast-frequency calculated - # Timing parameter constraints # - max-forecast-horizon <= predict-period, raise validation error if not respected # - if retrain_freq <= forecast-frequency, enforce retrain_freq = forecast-frequency don't crash From 2a805f0dffda7b2476e626363ba10522fbbe3568 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 11:23:28 +0100 Subject: [PATCH 018/141] refactor: move check to pre_load Signed-off-by: F.N. Claessen --- .../data/schemas/forecasting/pipeline.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 5129db2882..4e5306d3ce 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -294,8 +294,19 @@ class ForecasterParametersSchema(Schema): ) @pre_load - def drop_none_values(self, data, **kwargs): - return {k: v for k, v in data.items() if v is not None} + def sanitize_input(self, data, **kwargs): + + # Check predict period + if {"start", "end", "duration"} & data.keys(): + raise ValidationError( + "Provide 'duration' with either 'start' or 'end', but not with both.", + field_name="duration", + ) + + # Drop None values + data = {k: v for k, v in data.items() if v is not None} + + return data @validates_schema def validate_parameters(self, data: dict, **kwargs): # noqa: C901 @@ -365,14 +376,8 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 field_name="max_training_period", ) - @post_load(pass_original=True) - def resolve_config(self, data: dict, original_data, **kwargs) -> dict: # noqa: C901 - - if {"start", "end", "duration"} & original_data.keys(): - raise ValidationError( - "Provide 'duration' with either 'start' or 'end', but not with both.", - field_name="duration", - ) + @post_load + def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 target_sensor = data["sensor"] From 9e7acc903ae9d4e07c8fe9852c2b790f4200d3b8 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:32:38 +0100 Subject: [PATCH 019/141] dev: comment out tests cases that pass Signed-off-by: Mohamed Belhsan Hmida --- .../data/tests/test_train_predict_pipeline.py | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/flexmeasures/data/tests/test_train_predict_pipeline.py b/flexmeasures/data/tests/test_train_predict_pipeline.py index 629092e947..870c5752ef 100644 --- a/flexmeasures/data/tests/test_train_predict_pipeline.py +++ b/flexmeasures/data/tests/test_train_predict_pipeline.py @@ -17,28 +17,28 @@ @pytest.mark.parametrize( ["config", "params", "as_job", "expected_error"], [ - ( - { - # "model": "CustomLGBM", - }, - { - "sensor": "solar-sensor", - "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", - "output-path": None, - "start-date": "2025-01-01T00:00+02:00", - "end-date": "2025-01-03T00:00+02:00", - "train-period": "P2D", - "sensor-to-save": None, - "start-predict-date": "2025-01-02T00:00+02:00", - "retrain-frequency": "P0D", # 0 days is expected to fail - "max-forecast-horizon": "PT1H", - "forecast-frequency": "PT1H", - "probabilistic": False, - }, - False, - (ValidationError, "retrain-frequency must be greater than 0"), - ), - ( + # ( + # { + # # "model": "CustomLGBM", + # }, + # { + # "sensor": "solar-sensor", + # "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", + # "output-path": None, + # "start-date": "2025-01-01T00:00+02:00", + # "end-date": "2025-01-03T00:00+02:00", + # "train-period": "P2D", + # "sensor-to-save": None, + # "start-predict-date": "2025-01-02T00:00+02:00", + # "retrain-frequency": "P0D", # 0 days is expected to fail + # "max-forecast-horizon": "PT1H", + # "forecast-frequency": "PT1H", + # "probabilistic": False, + # }, + # False, + # (ValidationError, "retrain-frequency must be greater than 0"), + # ), + ( # this { # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], @@ -97,28 +97,28 @@ False, None, ), - ( - { - # "model": "CustomLGBM", - "future-regressors": ["irradiance-sensor"], - }, - { - "sensor": "solar-sensor", - "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", - "output-path": None, - "start-date": "2025-01-01T00:00+02:00", - "end-date": "2025-01-03T00:00+02:00", - "train-period": "P2D", - "sensor-to-save": None, - "start-predict-date": "2025-01-02T00:00+02:00", - "retrain-frequency": "P1D", - "max-forecast-horizon": "PT1H", - "forecast-frequency": "PT1H", - "probabilistic": False, - }, - False, - None, - ), + # ( + # { + # # "model": "CustomLGBM", + # "future-regressors": ["irradiance-sensor"], + # }, + # { + # "sensor": "solar-sensor", + # "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", + # "output-path": None, + # "start-date": "2025-01-01T00:00+02:00", + # "end-date": "2025-01-03T00:00+02:00", + # "train-period": "P2D", + # "sensor-to-save": None, + # "start-predict-date": "2025-01-02T00:00+02:00", + # "retrain-frequency": "P1D", + # "max-forecast-horizon": "PT1H", + # "forecast-frequency": "PT1H", + # "probabilistic": False, + # }, + # False, + # None, + # ), # ( # {}, # { From 6a5591c9fe8b11579c5b8c1145c641d3d4c0dd4f Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:37:12 +0100 Subject: [PATCH 020/141] fix: fix calculation for retrain_freq Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index fcf57173d6..ba8915d312 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -457,13 +457,14 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 if data.get("retrain_frequency") is None: if data.get("max_forecast_horizon") is None: - predict_period = planning_horizon + retrain_frequency_in_hours = planning_horizon else: - predict_period = min( - planning_horizon, data["max_forecast_horizon"] - ) # this is the iss + # If retrain_freq <= forecast-frequency, we enforce retrain_freq = forecast-frequency + retrain_frequency_in_hours = max( + planning_horizon, forecast_frequency + ) else: - predict_period = data["retrain_frequency"] + retrain_frequency_in_hours = data["retrain_frequency"] retrain_frequency_in_hours = predict_period // timedelta(hours=1) if retrain_frequency_in_hours < 1: @@ -482,6 +483,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 if model_save_dir is None: # Read default from schema model_save_dir = self.fields["model_save_dir"].load_default + return dict( target=target_sensor, model_save_dir=model_save_dir, From e4833dfdc6bb59d1d35abaa694460539d4b848b3 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:37:57 +0100 Subject: [PATCH 021/141] Revert "dev: comment out tests cases that pass" This reverts commit 9e7acc903ae9d4e07c8fe9852c2b790f4200d3b8. --- .../data/tests/test_train_predict_pipeline.py | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/flexmeasures/data/tests/test_train_predict_pipeline.py b/flexmeasures/data/tests/test_train_predict_pipeline.py index 870c5752ef..629092e947 100644 --- a/flexmeasures/data/tests/test_train_predict_pipeline.py +++ b/flexmeasures/data/tests/test_train_predict_pipeline.py @@ -17,28 +17,28 @@ @pytest.mark.parametrize( ["config", "params", "as_job", "expected_error"], [ - # ( - # { - # # "model": "CustomLGBM", - # }, - # { - # "sensor": "solar-sensor", - # "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", - # "output-path": None, - # "start-date": "2025-01-01T00:00+02:00", - # "end-date": "2025-01-03T00:00+02:00", - # "train-period": "P2D", - # "sensor-to-save": None, - # "start-predict-date": "2025-01-02T00:00+02:00", - # "retrain-frequency": "P0D", # 0 days is expected to fail - # "max-forecast-horizon": "PT1H", - # "forecast-frequency": "PT1H", - # "probabilistic": False, - # }, - # False, - # (ValidationError, "retrain-frequency must be greater than 0"), - # ), - ( # this + ( + { + # "model": "CustomLGBM", + }, + { + "sensor": "solar-sensor", + "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", + "output-path": None, + "start-date": "2025-01-01T00:00+02:00", + "end-date": "2025-01-03T00:00+02:00", + "train-period": "P2D", + "sensor-to-save": None, + "start-predict-date": "2025-01-02T00:00+02:00", + "retrain-frequency": "P0D", # 0 days is expected to fail + "max-forecast-horizon": "PT1H", + "forecast-frequency": "PT1H", + "probabilistic": False, + }, + False, + (ValidationError, "retrain-frequency must be greater than 0"), + ), + ( { # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], @@ -97,28 +97,28 @@ False, None, ), - # ( - # { - # # "model": "CustomLGBM", - # "future-regressors": ["irradiance-sensor"], - # }, - # { - # "sensor": "solar-sensor", - # "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", - # "output-path": None, - # "start-date": "2025-01-01T00:00+02:00", - # "end-date": "2025-01-03T00:00+02:00", - # "train-period": "P2D", - # "sensor-to-save": None, - # "start-predict-date": "2025-01-02T00:00+02:00", - # "retrain-frequency": "P1D", - # "max-forecast-horizon": "PT1H", - # "forecast-frequency": "PT1H", - # "probabilistic": False, - # }, - # False, - # None, - # ), + ( + { + # "model": "CustomLGBM", + "future-regressors": ["irradiance-sensor"], + }, + { + "sensor": "solar-sensor", + "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", + "output-path": None, + "start-date": "2025-01-01T00:00+02:00", + "end-date": "2025-01-03T00:00+02:00", + "train-period": "P2D", + "sensor-to-save": None, + "start-predict-date": "2025-01-02T00:00+02:00", + "retrain-frequency": "P1D", + "max-forecast-horizon": "PT1H", + "forecast-frequency": "PT1H", + "probabilistic": False, + }, + False, + None, + ), # ( # {}, # { From 6db140db538c909ff3d1cffcc5d4b803ea232c1a Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:39:37 +0100 Subject: [PATCH 022/141] style: run pre-commit Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index ba8915d312..316fbbf43e 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -460,9 +460,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 retrain_frequency_in_hours = planning_horizon else: # If retrain_freq <= forecast-frequency, we enforce retrain_freq = forecast-frequency - retrain_frequency_in_hours = max( - planning_horizon, forecast_frequency - ) + retrain_frequency_in_hours = max(planning_horizon, forecast_frequency) else: retrain_frequency_in_hours = data["retrain_frequency"] From a2819edf90ad8f018805cb0c3ffb125898c8fd9a Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 11:41:41 +0100 Subject: [PATCH 023/141] chore: regenerate openapi-spec.json Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/ui/static/openapi-specs.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 5be407a019..09956643a5 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4174,6 +4174,11 @@ "description": "Timestamp marking the start of training data. Defaults to train_period before start_predict_date if not set.", "example": "2025-01-01T00:00:00+01:00" }, + "duration": { + "type": "string", + "description": "The duration for which to create the forecast, also known as the planning horizon, in ISO 8601 duration format.", + "example": "PT24H" + }, "end-date": { "type": [ "string", From 930cf1c90fdb34ffb4023b0e3819e69e5b1468e7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:14:24 +0100 Subject: [PATCH 024/141] refactor: move parametrized cases next to case descriptions Signed-off-by: F.N. Claessen --- .../data/schemas/tests/test_forecasting.py | 198 ++++++++---------- 1 file changed, 82 insertions(+), 116 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 80029d54af..f8833abb94 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -9,75 +9,65 @@ @pytest.mark.parametrize( ["timing_input", "expected_timing_output"], [ + # todo: move this into the schema docstring + # Timing parameter defaults + # - predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) + # - max-forecast-horizon defaults to the predict-period + # - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) + # - retraining-frequency defaults to maximum of (FM planning horizon and forecast-frequency) so at this point we need forecast-frequency calculated + # Timing parameter constraints + # - max-forecast-horizon <= predict-period, raise validation error if not respected + # - if retrain_freq <= forecast-frequency, enforce retrain_freq = forecast-frequency don't crash + # # Case 0: no timing parameters are given # - # User expects to get forecasts for the default FM planning horizon from a single viewpoint. + # User expects to get forecasts for the default FM planning horizon from a single viewpoint (server now, floored to the hour). # Specifically, we expect: # - predict-period = FM planning horizon # - max-forecast-horizon = FM planning horizon # - forecast-frequency = FM planning horizon # - (config) retraining-frequency = FM planning horizon # - 1 cycle, 1 belief time + # - training-period = 30 days + ( + {}, + { + "predict-start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h"), + # default training period 30 days before predict start + "start-date": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h") + - pd.Timedelta(days=30), + # default prediction period 48 hours after predict start + "end-date": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h") + + pd.Timedelta(hours=48), + # these are set by the schema defaults + "predict-period-in-hours": 48, + "max-forecast-horizon": pd.Timedelta(days=2), + "train-period-in-hours": 720, + "max-training-period": pd.Timedelta(days=365), + "forecast-frequency": pd.Timedelta(days=2), + # server now + "save-belief-time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ), + "n_cycles": 1, + }, + ), # Case 1: predict-period = 12 hours # # User expects to get forecasts for the next 12 hours from a single viewpoint. # Specifically, we expect: - # - max-forecast-horizon = predict-period - # - forecast-frequency = predict-period + # - max-forecast-horizon = predict-period = 12 hours + # - forecast-frequency = predict-period = 12 hours # - (config) retraining-frequency = FM planning horizon # - 1 cycle, 1 belief time - # - # Case 2: max-forecast-horizon = 12 hours - # - # User expects to get forecasts for the next 12 hours from a single viewpoint (same as case 1). - # Specifically, we expect: - # - predict-period = 12 hours - # - forecast-frequency = max-forecast-horizon - # - retraining-period = FM planning horizon - # - 1 cycle, 1 belief time - # - # Case 3: forecast-frequency = 12 hours - # - # User expects to get forecasts for the default FM planning horizon from a new viewpoint every 12 hours. - # Specifically, we expect: - # - predict-period = FM planning horizon - # - max-forecast-horizon = predict-period (actual horizons are 48, 36, 24 and 12) - # - retraining-period = FM planning horizon - # - 1 cycle, 4 belief times - # - # Case 4: (config) retraining-period = 12 hours - # - # User expects to get forecasts for the default FM planning horizon from a new viewpoint every 12 hours (retraining at every viewpoint). - # Specifically, we expect: - # - predict-period = FM planning horizon - # - max-forecast-horizon = predict-period (actual horizons are 48, 36, 24 and 12) - # - forecast-frequency = retraining-period (capped by retraining-period, param changes based on config) - # - 4 cycles, 4 belief times - # Case 5: predict-period = 10 days and max-forecast-horizon = 12 hours - # - # User expects to get forecasts for the next 10 days from a new viewpoint every 12 hours. - # - forecast-frequency = max-forecast-horizon - # - retraining-frequency = FM planning horizon - # - 5 cycles, 20 belief times - # Case 6: predict-period = 12 hours and max-forecast-horizon = 10 days - # - # User expects that FM complains: the max-forecast-horizon should be lower than the predict-period - # - forecast-frequency = predict-period - # - retraining-frequency = FM planning horizon - # - 1 cycle, 1 belief time - # Timing parameter defaults - # - predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) - # - max-forecast-horizon defaults to the predict-period - # - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) - # - retraining-frequency defaults to maximum of (FM planning horizon and forecast-frequency) so at this point we need forecast-frequency calculated - # Timing parameter constraints - # - max-forecast-horizon <= predict-period, raise validation error if not respected - # - if retrain_freq <= forecast-frequency, enforce retrain_freq = forecast-frequency don't crash - # Case 1 user expectation: - # - Get forecasts for next 12 hours from a single viewpoint - # - max-forecast-horizon = 12 hours - # - forecast-frequency = 12 hours - # - 1 cycle + # - training-period = 30 days ( {"retrain-frequency": "PT12H"}, { @@ -103,11 +93,14 @@ "n_cycles": 1, }, ), - # Case 2 user expectation: - # - Same behavior as case 1 - # - predict-period = 12 hours - # - forecast-frequency = 12 hours - # - 1 cycle + # Case 2: max-forecast-horizon = 12 hours + # + # User expects to get forecasts for the next 12 hours from a single viewpoint (same as case 1). + # Specifically, we expect: + # - predict-period = 12 hours + # - forecast-frequency = max-forecast-horizon = 12 hours + # - retraining-period = FM planning horizon + # - 1 cycle, 1 belief time ( {"max-forecast-horizon": "PT12H"}, { @@ -133,13 +126,14 @@ "n_cycles": 1, }, ), - ### - # Case 3 user expectation: - # - Keep default planning horizon prediction window - # - New forecast viewpoint every 12 hours - # - max-forecast-horizon remains at planning horizon (48 hours) - # - 1 cycle, 4 belief times - # this fails + # Case 3: forecast-frequency = 12 hours + # + # User expects to get forecasts for the default FM planning horizon from a new viewpoint every 12 hours. + # Specifically, we expect: + # - predict-period = FM planning horizon + # - max-forecast-horizon = predict-period (actual horizons are 48, 36, 24 and 12) + # - retraining-period = FM planning horizon + # - 1 cycle, 4 belief times ( {"forecast-frequency": "PT12H"}, { @@ -165,11 +159,14 @@ "n_cycles": 1, }, ), - ### - # Case 4 user expectation: - # - Default planning horizon predictions, retraining every 12 hours - # - forecast-frequency follows retraining period (12 hours) - # - 4 cycles, 4 belief times + # Case 4: (config) retraining-period = 12 hours + # + # User expects to get forecasts for the default FM planning horizon from a new viewpoint every 12 hours (retraining at every viewpoint). + # Specifically, we expect: + # - predict-period = FM planning horizon + # - max-forecast-horizon = predict-period (actual horizons are 48, 36, 24 and 12) + # - forecast-frequency = predict-period (NOT capped by retraining-period, no param changes based on config) + # - 1 cycle, 1 belief time ( { "retrain-frequency": "PT12H", @@ -194,16 +191,15 @@ "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ), - "n_cycles": 4, + "n_cycles": 1, }, ), - ### - # Case 5 user expectation: - # - Predict-period = 10 days - # - max-forecast-horizon = 12 hours - # - forecast-frequency = 12 hours - # - 5 cycles, 20 belief times - # this fails + # Case 5: predict-period = 10 days and max-forecast-horizon = 12 hours + # + # User expects to get forecasts for the next 10 days from a new viewpoint every 12 hours. + # - forecast-frequency = max-forecast-horizon = 12 hours + # - retraining-frequency = FM planning horizon + # - 5 cycles, 20 belief times ( { "retrain-frequency": "P10D", @@ -232,9 +228,12 @@ "n_cycles": 5, }, ), - # Case 6 user expectation: - # - FM should complain: max-forecast-horizon must be <= predict-period - # this should fails it expects a validation error + # Case 6: predict-period = 12 hours and max-forecast-horizon = 10 days + # + # User expects that FM complains: the max-forecast-horizon should be lower than the predict-period + # - forecast-frequency = predict-period + # - retraining-frequency = FM planning horizon + # - 1 cycle, 1 belief time ( { "retrain-frequency": "PT12H", @@ -263,39 +262,6 @@ "n_cycles": 1, }, ), - ### - # We expect training period of 30 days before predict start and prediction period of 48 hours after predict start, with predict start at server now (floored to hour). - # 1 cycle expected (1 belief time for forecast) given the forecast frequency equal defaulted to prediction period of 48 hours. - ( - {}, - { - "predict-start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h"), - # default training period 30 days before predict start - "start-date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), - # default prediction period 48 hours after predict start - "end-date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - + pd.Timedelta(hours=48), - # these are set by the schema defaults - "predict-period-in-hours": 48, - "max-forecast-horizon": pd.Timedelta(days=2), - "train-period-in-hours": 720, - "max-training-period": pd.Timedelta(days=365), - "forecast-frequency": pd.Timedelta(days=2), - # server now - "save-belief-time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ), - "n_cycles": 1, - }, - ), # Test defaults when only an end date is given # We expect training period of 30 days before predict start and prediction period of 5 days after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief time for forecast) given the forecast frequency equal defaulted to prediction period of 5 days. From e8fab7ec912a9526aaa07346043a118c9f7329bd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:16:18 +0100 Subject: [PATCH 025/141] dev: remove breakpoint Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index f8833abb94..1209482f72 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -534,7 +534,6 @@ def test_timing_parameters_of_forecaster_parameters_schema( } ) - breakpoint() for k, v in expected_timing_output.items(): # Convert kebab-case key to snake_case to match data dictionary keys returned by schema snake_key = kebab_to_snake(k) From 875ad1abf64e67884ea16c784caeebc040eef516 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:22:16 +0100 Subject: [PATCH 026/141] fix: check predict period Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 316fbbf43e..9d95126d67 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -297,7 +297,7 @@ class ForecasterParametersSchema(Schema): def sanitize_input(self, data, **kwargs): # Check predict period - if {"start", "end", "duration"} & data.keys(): + if len({"start", "end", "duration"} & data.keys()) > 2: raise ValidationError( "Provide 'duration' with either 'start' or 'end', but not with both.", field_name="duration", From cb80192abd1cc949f8f61874530a92110b0db07c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:22:33 +0100 Subject: [PATCH 027/141] fix: case 1 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 1209482f72..872fbe08f6 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -69,7 +69,7 @@ # - 1 cycle, 1 belief time # - training-period = 30 days ( - {"retrain-frequency": "PT12H"}, + {"duration": "PT12H"}, { "predict_start": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" From 72ff44faf8f6956c656220dcd0dec02915ddbf90 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:24:25 +0100 Subject: [PATCH 028/141] feat: improve error message for failing test cases Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 872fbe08f6..5a56b7a069 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -537,4 +537,4 @@ def test_timing_parameters_of_forecaster_parameters_schema( for k, v in expected_timing_output.items(): # Convert kebab-case key to snake_case to match data dictionary keys returned by schema snake_key = kebab_to_snake(k) - assert data[snake_key] == v + assert data[snake_key] == v, f"{k} did not match expectations." From 90bb7cf56d3aaf40e61e72c8b1e24aa3814b5946 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:26:06 +0100 Subject: [PATCH 029/141] dev: case 2 needs further investigation Signed-off-by: F.N. Claessen --- .../data/schemas/tests/test_forecasting.py | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 5a56b7a069..97ea3cf953 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -101,31 +101,31 @@ # - forecast-frequency = max-forecast-horizon = 12 hours # - retraining-period = FM planning horizon # - 1 cycle, 1 belief time - ( - {"max-forecast-horizon": "PT12H"}, - { - "predict_start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h"), - "start_date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), - "train_period_in_hours": 720, - "predict_period_in_hours": 12, - "max_forecast_horizon": pd.Timedelta(hours=12), - "forecast_frequency": pd.Timedelta(hours=12), - "end_date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(hours=12), - "max_training_period": pd.Timedelta(days=365), - "save_belief_time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ), - "n_cycles": 1, - }, - ), + # ( + # {"max-forecast-horizon": "PT12H"}, + # { + # "predict_start": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h"), + # "start_date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h") + # - pd.Timedelta(days=30), + # "train_period_in_hours": 720, + # "predict_period_in_hours": 12, + # "max_forecast_horizon": pd.Timedelta(hours=12), + # "forecast_frequency": pd.Timedelta(hours=12), + # "end_date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(hours=12), + # "max_training_period": pd.Timedelta(days=365), + # "save_belief_time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ), + # "n_cycles": 1, + # }, + # ), # Case 3: forecast-frequency = 12 hours # # User expects to get forecasts for the default FM planning horizon from a new viewpoint every 12 hours. From d498436c8df4f3bfe92b3c5b9ee468b92436e908 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:27:40 +0100 Subject: [PATCH 030/141] fix: case 4 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 97ea3cf953..fbf5798fb0 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -181,9 +181,9 @@ ).floor("1h") - pd.Timedelta(days=30), "train_period_in_hours": 720, - "predict_period_in_hours": 12, - "max_forecast_horizon": pd.Timedelta(hours=12), - "forecast_frequency": pd.Timedelta(hours=12), + "predict_period_in_hours": 48, + "max_forecast_horizon": pd.Timedelta(hours=48), + "forecast_frequency": pd.Timedelta(hours=48), "end_date": pd.Timestamp( "2025-01-17T12:00:00+01", tz="Europe/Amsterdam" ), From a5128e7af48690cde9490067026f9dbce1c15e0b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:29:26 +0100 Subject: [PATCH 031/141] fix: partially fix case 5 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index fbf5798fb0..a96035919b 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -202,7 +202,7 @@ # - 5 cycles, 20 belief times ( { - "retrain-frequency": "P10D", + "duration": "P10D", "max-forecast-horizon": "PT12H", }, { From 4926de73f929e4c681ce5970f858aa80ca7391d5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:30:02 +0100 Subject: [PATCH 032/141] dev: case 5 needs further investigation Signed-off-by: F.N. Claessen --- .../data/schemas/tests/test_forecasting.py | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index a96035919b..758310cfcf 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -200,34 +200,34 @@ # - forecast-frequency = max-forecast-horizon = 12 hours # - retraining-frequency = FM planning horizon # - 5 cycles, 20 belief times - ( - { - "duration": "P10D", - "max-forecast-horizon": "PT12H", - }, - { - "predict_start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h"), - "start_date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), - "train_period_in_hours": 720, - "predict_period_in_hours": 240, - "max_forecast_horizon": pd.Timedelta(hours=12), - "forecast_frequency": pd.Timedelta(hours=12), - "end_date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=10), - "max_training_period": pd.Timedelta(days=365), - "save_belief_time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ), - "n_cycles": 5, - }, - ), + # ( + # { + # "duration": "P10D", + # "max-forecast-horizon": "PT12H", + # }, + # { + # "predict_start": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h"), + # "start_date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h") + # - pd.Timedelta(days=30), + # "train_period_in_hours": 720, + # "predict_period_in_hours": 240, + # "max_forecast_horizon": pd.Timedelta(hours=12), + # "forecast_frequency": pd.Timedelta(hours=12), + # "end_date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(days=10), + # "max_training_period": pd.Timedelta(days=365), + # "save_belief_time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ), + # "n_cycles": 5, + # }, + # ), # Case 6: predict-period = 12 hours and max-forecast-horizon = 10 days # # User expects that FM complains: the max-forecast-horizon should be lower than the predict-period From bcf1d4040df8e026142a9bd2dfc493b268e80ead Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:46:11 +0100 Subject: [PATCH 033/141] fix: case 6 Signed-off-by: F.N. Claessen --- .../data/schemas/tests/test_forecasting.py | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 758310cfcf..30b826e607 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -1,5 +1,6 @@ import pytest +from marshmallow import ValidationError import pandas as pd from flexmeasures.data.schemas.forecasting.pipeline import ForecasterParametersSchema @@ -236,31 +237,16 @@ # - 1 cycle, 1 belief time ( { - "retrain-frequency": "PT12H", + "duration": "PT12H", "max-forecast-horizon": "P10D", }, - { - "predict_start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h"), - "start_date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), - "train_period_in_hours": 720, - "predict_period_in_hours": 12, - "max_forecast_horizon": pd.Timedelta(days=10), - "forecast_frequency": pd.Timedelta(days=10), - "end_date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(hours=12), - "max_training_period": pd.Timedelta(days=365), - "save_belief_time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ), - "n_cycles": 1, - }, + ValidationError( + { + "max_forecast_horizon": [ + "max-forecast-horizon must be less than or equal to predict-period" + ] + } + ), ), # Test defaults when only an end date is given # We expect training period of 30 days before predict start and prediction period of 5 days after predict start, with predict start at server now (floored to hour). @@ -527,6 +513,16 @@ def test_timing_parameters_of_forecaster_parameters_schema( pd.Timestamp("2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam") ) + if isinstance(expected_timing_output, ValidationError): + with pytest.raises(ValidationError) as exc: + ForecasterParametersSchema().load( + { + "sensor": 1, + **timing_input, + } + ) + assert exc.value.messages == expected_timing_output.messages + return data = ForecasterParametersSchema().load( { "sensor": 1, From 1334308b4e8f58843923d6ae91cc76abffc39171 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 12:48:16 +0100 Subject: [PATCH 034/141] dev: comment out test cases that need further investigation, and preferably these should also become enumerated cases with similarly annotated expectations Signed-off-by: F.N. Claessen --- .../data/schemas/tests/test_forecasting.py | 400 +++++++++--------- 1 file changed, 200 insertions(+), 200 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 30b826e607..f3859a8834 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -251,116 +251,116 @@ # Test defaults when only an end date is given # We expect training period of 30 days before predict start and prediction period of 5 days after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief time for forecast) given the forecast frequency equal defaulted to prediction period of 5 days. - ( - {"end-date": "2025-01-20T12:00:00+01:00"}, - { - "predict-start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ).floor("1h"), - "start-date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ).floor("1h") - - pd.Timedelta( - days=30 - ), # default training period 30 days before predict start - "end-date": pd.Timestamp( - "2025-01-20T12:00:00+01", - tz="Europe/Amsterdam", - ), - "train-period-in-hours": 720, # from start date to predict start - "predict-period-in-hours": 120, # from predict start to end date - "forecast-frequency": pd.Timedelta( - days=5 - ), # duration between predict start and end date - "max-forecast-horizon": pd.Timedelta( - days=5 - ), # duration between predict start and end date - # default values - "max-training-period": pd.Timedelta(days=365), - # server now - "save-belief-time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ), - "n_cycles": 1, - }, - ), + # ( + # {"end-date": "2025-01-20T12:00:00+01:00"}, + # { + # "predict-start": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ).floor("1h"), + # "start-date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ).floor("1h") + # - pd.Timedelta( + # days=30 + # ), # default training period 30 days before predict start + # "end-date": pd.Timestamp( + # "2025-01-20T12:00:00+01", + # tz="Europe/Amsterdam", + # ), + # "train-period-in-hours": 720, # from start date to predict start + # "predict-period-in-hours": 120, # from predict start to end date + # "forecast-frequency": pd.Timedelta( + # days=5 + # ), # duration between predict start and end date + # "max-forecast-horizon": pd.Timedelta( + # days=5 + # ), # duration between predict start and end date + # # default values + # "max-training-period": pd.Timedelta(days=365), + # # server now + # "save-belief-time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ), + # "n_cycles": 1, + # }, + # ), # Test when both start and end dates are given # We expect training period of 26.5 days (636 hours) from the given start date and predict start, prediction period of 108 hours duration from predict start to end date, with predict_start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period - ( - { - "start-date": "2024-12-20T00:00:00+01:00", - "end-date": "2025-01-20T00:00:00+01:00", - }, - { - "start-date": pd.Timestamp( - "2024-12-20T00:00:00+01", tz="Europe/Amsterdam" - ), - "end-date": pd.Timestamp( - "2025-01-20T00:00:00+01", tz="Europe/Amsterdam" - ), - "predict-start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ).floor("1h"), - "predict-period-in-hours": 108, # hours from predict start to end date - "train-period-in-hours": 636, # hours between start date and predict start - "max-forecast-horizon": pd.Timedelta(days=4) - + pd.Timedelta(hours=12), # duration between predict start and end date - "forecast-frequency": pd.Timedelta(days=4) - + pd.Timedelta(hours=12), # duration between predict start and end date - # default values - "max-training-period": pd.Timedelta(days=365), - # server now - "save-belief-time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ), - "n_cycles": 1, - }, - ), + # ( + # { + # "start-date": "2024-12-20T00:00:00+01:00", + # "end-date": "2025-01-20T00:00:00+01:00", + # }, + # { + # "start-date": pd.Timestamp( + # "2024-12-20T00:00:00+01", tz="Europe/Amsterdam" + # ), + # "end-date": pd.Timestamp( + # "2025-01-20T00:00:00+01", tz="Europe/Amsterdam" + # ), + # "predict-start": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ).floor("1h"), + # "predict-period-in-hours": 108, # hours from predict start to end date + # "train-period-in-hours": 636, # hours between start date and predict start + # "max-forecast-horizon": pd.Timedelta(days=4) + # + pd.Timedelta(hours=12), # duration between predict start and end date + # "forecast-frequency": pd.Timedelta(days=4) + # + pd.Timedelta(hours=12), # duration between predict start and end date + # # default values + # "max-training-period": pd.Timedelta(days=365), + # # server now + # "save-belief-time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ), + # "n_cycles": 1, + # }, + # ), # Test when only end date is given with a training period # We expect the start date to be computed with respect to now. (training period before now (floored)). # We expect training period of 30 days before predict start and prediction period of 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period - ( - { - "end-date": "2025-01-20T12:00:00+01:00", - "train-period": "P3D", - }, - { - "end-date": pd.Timestamp( - "2025-01-20T12:00:00+01", tz="Europe/Amsterdam" - ), - "predict-start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ).floor("1h"), - "start-date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - - pd.Timedelta(days=3), - "train-period-in-hours": 72, # from start date to predict start - "predict-period-in-hours": 120, # from predict start to end date - "max-forecast-horizon": pd.Timedelta( - days=5 - ), # duration between predict start and end date - "forecast-frequency": pd.Timedelta( - days=5 - ), # duration between predict start and end date - # default values - "max-training-period": pd.Timedelta(days=365), - # server now - "save-belief-time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ), - "n_cycles": 1, - }, - ), + # ( + # { + # "end-date": "2025-01-20T12:00:00+01:00", + # "train-period": "P3D", + # }, + # { + # "end-date": pd.Timestamp( + # "2025-01-20T12:00:00+01", tz="Europe/Amsterdam" + # ), + # "predict-start": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ).floor("1h"), + # "start-date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # - pd.Timedelta(days=3), + # "train-period-in-hours": 72, # from start date to predict start + # "predict-period-in-hours": 120, # from predict start to end date + # "max-forecast-horizon": pd.Timedelta( + # days=5 + # ), # duration between predict start and end date + # "forecast-frequency": pd.Timedelta( + # days=5 + # ), # duration between predict start and end date + # # default values + # "max-training-period": pd.Timedelta(days=365), + # # server now + # "save-belief-time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ), + # "n_cycles": 1, + # }, + # ), # Test when only start date is given with a training period # We expect the predict start to be computed with respect to the start date (training period after start date). # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). @@ -401,109 +401,109 @@ # We expect the predict start to be computed with respect to the start date (training period after start date). # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period - ( - { - "start-date": "2024-12-25T00:00:00+01:00", - "retrain-frequency": "P3D", - }, - { - "start-date": pd.Timestamp( - "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" - ), - "predict-start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ).floor("1h"), - "end-date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=3), - "predict-period-in-hours": 72, - "train-period-in-hours": 516, # from start-date to predict-start - "max-forecast-horizon": pd.Timedelta( - days=3 - ), # duration between predict-start and end-date - "forecast-frequency": pd.Timedelta( - days=3 - ), # duration between predict-start and end-date - # default values - "max-training-period": pd.Timedelta(days=365), - # server now - "save-belief-time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ), - "n_cycles": 1, - }, - ), + # ( + # { + # "start-date": "2024-12-25T00:00:00+01:00", + # "retrain-frequency": "P3D", + # }, + # { + # "start-date": pd.Timestamp( + # "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" + # ), + # "predict-start": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ).floor("1h"), + # "end-date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(days=3), + # "predict-period-in-hours": 72, + # "train-period-in-hours": 516, # from start-date to predict-start + # "max-forecast-horizon": pd.Timedelta( + # days=3 + # ), # duration between predict-start and end-date + # "forecast-frequency": pd.Timedelta( + # days=3 + # ), # duration between predict-start and end-date + # # default values + # "max-training-period": pd.Timedelta(days=365), + # # server now + # "save-belief-time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ), + # "n_cycles": 1, + # }, + # ), # Test when only start date is given with both training period 20 days and retrain frequency 3 days # We expect the predict start to be computed with respect to the start date (training period after start date). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period - ( - { - "start-date": "2024-12-01T00:00:00+01:00", - "train-period": "P20D", - "retrain-frequency": "P3D", - }, - { - "start-date": pd.Timestamp( - "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - ), - "predict-start": pd.Timestamp( - "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=20), - "end-date": pd.Timestamp( - "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=23), - "train-period-in-hours": 480, - "predict-period-in-hours": 72, - "max-forecast-horizon": pd.Timedelta(days=3), # predict period duration - "forecast-frequency": pd.Timedelta(days=3), # predict period duration - # default values - "max-training-period": pd.Timedelta(days=365), - # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency - "save-belief-time": None, - }, - ), + # ( + # { + # "start-date": "2024-12-01T00:00:00+01:00", + # "train-period": "P20D", + # "retrain-frequency": "P3D", + # }, + # { + # "start-date": pd.Timestamp( + # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + # ), + # "predict-start": pd.Timestamp( + # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(days=20), + # "end-date": pd.Timestamp( + # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(days=23), + # "train-period-in-hours": 480, + # "predict-period-in-hours": 72, + # "max-forecast-horizon": pd.Timedelta(days=3), # predict period duration + # "forecast-frequency": pd.Timedelta(days=3), # predict period duration + # # default values + # "max-training-period": pd.Timedelta(days=365), + # # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency + # "save-belief-time": None, + # }, + # ), # Test when only end date is given with a prediction period: we expect the train start and predict start to both be computed with respect to the end date. # we expect training period of 30 days before predict_start and prediction period of 3 days after predict_start, with predict_start at server now (floored to hour). # we expect 2 cycles from the retrain frequency and predict period given the end date - ( - { - "end-date": "2025-01-21T12:00:00+01:00", - "retrain-frequency": "P3D", - }, - { - "end-date": pd.Timestamp( - "2025-01-21T12:00:00+01", tz="Europe/Amsterdam" - ), - "predict-start": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ), - "start-date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - - pd.Timedelta(days=30), - "predict-period-in-hours": 72, - "train-period-in-hours": 720, - "max-forecast-horizon": pd.Timedelta( - days=3 - ), # duration between predict start and end date (retrain frequency) - "forecast-frequency": pd.Timedelta( - days=3 - ), # duration between predict start and end date (retrain frequency) - # default values - "max-training-period": pd.Timedelta(days=365), - # server now - "save-belief-time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ), - "n_cycles": 2, # we expect 2 cycles from the retrain frequency and predict period given the end date - }, - ), + # ( + # { + # "end-date": "2025-01-21T12:00:00+01:00", + # "retrain-frequency": "P3D", + # }, + # { + # "end-date": pd.Timestamp( + # "2025-01-21T12:00:00+01", tz="Europe/Amsterdam" + # ), + # "predict-start": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ), + # "start-date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # - pd.Timedelta(days=30), + # "predict-period-in-hours": 72, + # "train-period-in-hours": 720, + # "max-forecast-horizon": pd.Timedelta( + # days=3 + # ), # duration between predict start and end date (retrain frequency) + # "forecast-frequency": pd.Timedelta( + # days=3 + # ), # duration between predict start and end date (retrain frequency) + # # default values + # "max-training-period": pd.Timedelta(days=365), + # # server now + # "save-belief-time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ), + # "n_cycles": 2, # we expect 2 cycles from the retrain frequency and predict period given the end date + # }, + # ), ], ) def test_timing_parameters_of_forecaster_parameters_schema( From 79dec918493207e707ca8d50d8b146e4b975af25 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 13:37:58 +0100 Subject: [PATCH 035/141] docs: move the documented defaults and choices for timing parameters to the post_load docstring where these are actually acted upon Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 11 +++++++++++ flexmeasures/data/schemas/tests/test_forecasting.py | 10 ---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 9d95126d67..9b76cd6c3d 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -378,6 +378,17 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 @post_load def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 + """Resolve timing parameters, using sensible defaults and choices. + + Defaults: + - predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) + - max-forecast-horizon defaults to the predict-period + - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) + + Choices: + - If max-forecast-horizon <= predict-period, we raise a ValidationError due to incomplete coverage + - retraining-frequency becomes the maximum of (FM planning horizon and forecast-frequency) + """ target_sensor = data["sensor"] diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index f3859a8834..fbed7d60d3 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -10,16 +10,6 @@ @pytest.mark.parametrize( ["timing_input", "expected_timing_output"], [ - # todo: move this into the schema docstring - # Timing parameter defaults - # - predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) - # - max-forecast-horizon defaults to the predict-period - # - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) - # - retraining-frequency defaults to maximum of (FM planning horizon and forecast-frequency) so at this point we need forecast-frequency calculated - # Timing parameter constraints - # - max-forecast-horizon <= predict-period, raise validation error if not respected - # - if retrain_freq <= forecast-frequency, enforce retrain_freq = forecast-frequency don't crash - # # Case 0: no timing parameters are given # # User expects to get forecasts for the default FM planning horizon from a single viewpoint (server now, floored to the hour). From 415dc0cc85c77444e624784640430f4498c79092 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 13:41:40 +0100 Subject: [PATCH 036/141] fix: correctly set retrain_frequency_in_hours Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 9d95126d67..1e7f59c7c2 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -463,8 +463,10 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 retrain_frequency_in_hours = max(planning_horizon, forecast_frequency) else: retrain_frequency_in_hours = data["retrain_frequency"] + retrain_frequency_in_hours = int( + retrain_frequency_in_hours.total_seconds() / 3600 + ) - retrain_frequency_in_hours = predict_period // timedelta(hours=1) if retrain_frequency_in_hours < 1: raise ValidationError("retrain-frequency must be at least 1 hour") From a217df01edf4a08af66f3b853c7123ebccb74726 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 13:42:43 +0100 Subject: [PATCH 037/141] fix: streamline job metadata handling in run method to prevent undefined variable issue Signed-off-by: Mohamed Belhsan Hmida --- .../forecasting/pipelines/train_predict.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 8973b0e265..05dbb29139 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -213,14 +213,16 @@ def run( if as_job: cycle_job_ids = [] + + # job metadata for tracking + job_metadata = { + "data_source_info": {"id": self.data_source.id}, + "start_predict_date": self._parameters["predict_start"], + "end_date": self._parameters["end_date"], + "sensor_id": self._parameters["sensor_to_save"].id, + } for cycle_params in cycles_job_params: - # job metadata for tracking - job_metadata = { - "data_source_info": {"id": self.data_source.id}, - "start_predict_date": self._parameters["predict_start"], - "end_date": self._parameters["end_date"], - "sensor_id": self._parameters["sensor_to_save"].id, - } + job = Job.create( self.run_cycle, # Some cycle job params override job kwargs @@ -270,6 +272,6 @@ def run( return wrap_up_job.id else: # Return the single cycle job ID if only one job is queued - return cycle_job_ids[0] + return cycle_job_ids[0] if len(cycle_job_ids) == 1 else wrap_up_job.id return self.return_values From 8dd8ecb20a8fe1aaa94a24a4efc201dc7fcc8fd3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 13:43:57 +0100 Subject: [PATCH 038/141] refactor: rename parameter name to match field name Signed-off-by: F.N. Claessen --- .../forecasting/pipelines/train_predict.py | 18 +++++++++--------- .../data/schemas/forecasting/pipeline.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 8973b0e265..db2f2f7445 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -72,11 +72,11 @@ def run_cycle( train_pipeline = TrainPipeline( future_regressors=self._config["future_regressors"], past_regressors=self._config["past_regressors"], - target_sensor=self._parameters["target"], + target_sensor=self._parameters["sensor"], model_save_dir=self._parameters["model_save_dir"], n_steps_to_predict=self._parameters["train_period_in_hours"] * multiplier, max_forecast_horizon=self._parameters["max_forecast_horizon"] - // self._parameters["target"].event_resolution, + // self._parameters["sensor"].event_resolution, event_starts_after=train_start, event_ends_before=train_end, probabilistic=self._parameters["probabilistic"], @@ -95,7 +95,7 @@ def run_cycle( predict_pipeline = PredictPipeline( future_regressors=self._config["future_regressors"], past_regressors=self._config["past_regressors"], - target_sensor=self._parameters["target"], + target_sensor=self._parameters["sensor"], model_path=os.path.join( self._parameters["model_save_dir"], f"sensor_{self._parameters['target'].id}-cycle_{counter}-lgbm.pkl", @@ -110,9 +110,9 @@ def run_cycle( ), n_steps_to_predict=self._parameters["predict_period_in_hours"] * multiplier, max_forecast_horizon=self._parameters["max_forecast_horizon"] - // self._parameters["target"].event_resolution, + // self._parameters["sensor"].event_resolution, forecast_frequency=self._parameters["forecast_frequency"] - // self._parameters["target"].event_resolution, + // self._parameters["sensor"].event_resolution, probabilistic=self._parameters["probabilistic"], event_starts_after=train_start, # use beliefs about events before the start of the predict period event_ends_before=predict_end, # ignore any beliefs about events beyond the end of the predict period @@ -140,7 +140,7 @@ def run_cycle( f"{p.ordinal(counter)} Train-Predict cycle from {train_start} to {predict_end} completed in {total_runtime:.2f} seconds." ) self.return_values.append( - {"data": forecasts, "sensor": self._parameters["target"]} + {"data": forecasts, "sensor": self._parameters["sensor"]} ) return total_runtime @@ -168,7 +168,7 @@ def run( train_end = predict_start counter = 0 - sensor_resolution = self._parameters["target"].event_resolution + sensor_resolution = self._parameters["sensor"].event_resolution multiplier = int( timedelta(hours=1) / sensor_resolution ) # multiplier used to adapt n_steps_to_predict to hours from sensor resolution, e.g. 15 min sensor resolution will have 7*24*4 = 168 predicitons to predict a week @@ -191,7 +191,7 @@ def run( cycle_runtime = self.run_cycle(**train_predict_params) cumulative_cycles_runtime += cycle_runtime else: - train_predict_params["target_sensor_id"] = self._parameters["target"].id + train_predict_params["target_sensor_id"] = self._parameters["sensor"].id cycles_job_params.append(train_predict_params) # Move forward to the next cycle one prediction period later @@ -245,7 +245,7 @@ def run( current_app.queues[queue].enqueue_job(job) current_app.job_cache.add( - self._parameters["target"].id, + self._parameters["sensor"].id, job_id=job.id, queue=queue, asset_or_sensor_type="sensor", diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 9b76cd6c3d..4402a4e5c9 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -494,7 +494,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 model_save_dir = self.fields["model_save_dir"].load_default return dict( - target=target_sensor, + sensor=target_sensor, model_save_dir=model_save_dir, output_path=output_path, start_date=start_date, From cea8afc1bbbbc5acf39fff83488668359ee63f87 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 13:54:43 +0100 Subject: [PATCH 039/141] fix: stop mixing up retrain-frequency and predict-period Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 81972e9ba4..1e282b3b54 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -477,6 +477,9 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 retrain_frequency_in_hours = int( retrain_frequency_in_hours.total_seconds() / 3600 ) + predict_period_in_hours = int( + predict_period.total_seconds() / 3600 + ) if retrain_frequency_in_hours < 1: raise ValidationError("retrain-frequency must be at least 1 hour") @@ -504,15 +507,14 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 train_period_in_hours=train_period_in_hours, max_training_period=max_training_period, predict_start=predict_start, - predict_period_in_hours=retrain_frequency_in_hours, + predict_period_in_hours=predict_period_in_hours, max_forecast_horizon=max_forecast_horizon, forecast_frequency=forecast_frequency, probabilistic=data["probabilistic"], sensor_to_save=sensor_to_save, save_belief_time=save_belief_time, n_cycles=int( - (data["end_date"] - predict_start) - // timedelta(hours=retrain_frequency_in_hours) + predict_period // timedelta(hours=retrain_frequency_in_hours) ), ) From b98d8976ce8d16c99c1d486707512b6c46d1ba67 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 13:55:39 +0100 Subject: [PATCH 040/141] fix: false variable name Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 1e282b3b54..7c307b5815 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -468,14 +468,14 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 if data.get("retrain_frequency") is None: if data.get("max_forecast_horizon") is None: - retrain_frequency_in_hours = planning_horizon + retrain_frequency = planning_horizon else: # If retrain_freq <= forecast-frequency, we enforce retrain_freq = forecast-frequency - retrain_frequency_in_hours = max(planning_horizon, forecast_frequency) + retrain_frequency = max(planning_horizon, forecast_frequency) else: - retrain_frequency_in_hours = data["retrain_frequency"] + retrain_frequency = data["retrain_frequency"] retrain_frequency_in_hours = int( - retrain_frequency_in_hours.total_seconds() / 3600 + retrain_frequency.total_seconds() / 3600 ) predict_period_in_hours = int( predict_period.total_seconds() / 3600 From 08328192a50f187920760a3934f25dd88c28b336 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 13:55:55 +0100 Subject: [PATCH 041/141] style: black Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 7c307b5815..340f5c3ec1 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -474,12 +474,8 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 retrain_frequency = max(planning_horizon, forecast_frequency) else: retrain_frequency = data["retrain_frequency"] - retrain_frequency_in_hours = int( - retrain_frequency.total_seconds() / 3600 - ) - predict_period_in_hours = int( - predict_period.total_seconds() / 3600 - ) + retrain_frequency_in_hours = int(retrain_frequency.total_seconds() / 3600) + predict_period_in_hours = int(predict_period.total_seconds() / 3600) if retrain_frequency_in_hours < 1: raise ValidationError("retrain-frequency must be at least 1 hour") @@ -513,9 +509,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 probabilistic=data["probabilistic"], sensor_to_save=sensor_to_save, save_belief_time=save_belief_time, - n_cycles=int( - predict_period // timedelta(hours=retrain_frequency_in_hours) - ), + n_cycles=int(predict_period // timedelta(hours=retrain_frequency_in_hours)), ) From d16ad4ec1433c05ce48901b07746f7fd1e5b568f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 13:58:17 +0100 Subject: [PATCH 042/141] fix: cap retrain-frequency to not exceed predict-period Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 340f5c3ec1..7620a276e7 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -474,6 +474,8 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 retrain_frequency = max(planning_horizon, forecast_frequency) else: retrain_frequency = data["retrain_frequency"] + if retrain_frequency > predict_period: + retrain_frequency = predict_period retrain_frequency_in_hours = int(retrain_frequency.total_seconds() / 3600) predict_period_in_hours = int(predict_period.total_seconds() / 3600) From 25b49a3551942b285f680bdfc532377a1faa77b5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 15:12:07 +0100 Subject: [PATCH 043/141] fix: incomplete schema renaming Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/__init__.py | 2 +- flexmeasures/api/v3_0/sensors.py | 2 +- flexmeasures/ui/static/openapi-specs.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/flexmeasures/api/v3_0/__init__.py b/flexmeasures/api/v3_0/__init__.py index dd4c5a487a..c85e777ba4 100644 --- a/flexmeasures/api/v3_0/__init__.py +++ b/flexmeasures/api/v3_0/__init__.py @@ -140,7 +140,7 @@ def create_openapi_specs(app: Flask): # Explicitly register OpenAPI-compatible schemas schemas = [ ("FlexContextOpenAPISchema", flex_context_schema_openAPI), - ("forecaster_parameters_schema_openAPI", forecasting_trigger_schema_openAPI), + ("forecasting_trigger_schema_openAPI", forecasting_trigger_schema_openAPI), ("UserAPIQuerySchema", UserAPIQuerySchema), ("AssetAPIQuerySchema", AssetAPIQuerySchema), ("AssetSchema", AssetSchema), diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index a418beb1cf..efb4bfde4e 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1558,7 +1558,7 @@ def trigger_forecast(self, id: int, **params): required: true content: application/json: - schema: forecaster_parameters_schema_openAPI + schema: forecasting_trigger_schema_openAPI example: start-date: "2026-01-01T00:00:00+01:00" start-predict-date: "2026-01-15T00:00:00+01:00" diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 09956643a5..34a7f229c4 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1189,7 +1189,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/forecaster_parameters_schema_openAPI" + "$ref": "#/components/schemas/forecasting_trigger_schema_openAPI" }, "example": { "start-date": "2026-01-01T00:00:00+01:00", @@ -4157,7 +4157,7 @@ }, "additionalProperties": false }, - "forecaster_parameters_schema_openAPI": { + "forecasting_trigger_schema_openAPI": { "type": "object", "properties": { "sensor": { From dcddd64e2e107c3ae0691f3d0673f20b454ed7aa Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 15:17:02 +0100 Subject: [PATCH 044/141] fix: exclude CLI-specific fields from API schema Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/sensors.py | 16 +++++++++-- flexmeasures/ui/static/openapi-specs.json | 35 ----------------------- 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index efb4bfde4e..5145623bfe 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -82,7 +82,16 @@ partial_sensor_schema = SensorSchema(partial=True, exclude=["generic_asset_id"]) # Create ForecasterParametersSchema OpenAPI compatible schema -forecasting_trigger_schema_openAPI = make_openapi_compatible(ForecastingTriggerSchema) +EXCLUDED_FORECASTING_FIELDS = [ + "train_period", + "max_training_period", + "forecast_frequency", + "sensor_to_save", +] +forecasting_trigger_schema_openAPI = make_openapi_compatible(ForecastingTriggerSchema)( + partial=True, + exclude=EXCLUDED_FORECASTING_FIELDS, +) class SensorKwargsSchema(Schema): @@ -1524,7 +1533,10 @@ def get_status(self, id, sensor): @route("//forecasts/trigger", methods=["POST"]) @use_args( - ForecastingTriggerSchema(), + ForecastingTriggerSchema( + partial=True, + exclude=EXCLUDED_FORECASTING_FIELDS, + ), location="combined_sensor_data_description", as_kwargs=True, ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 34a7f229c4..a400d279a6 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4188,14 +4188,6 @@ "description": "End date for running the pipeline.", "example": "2025-10-15T00:00:00+01:00" }, - "train-period": { - "type": [ - "string", - "null" - ], - "description": "Duration of the initial training period (ISO 8601 format, min 2 days). If not set, derived from start_date and start_predict_date or defaults to P30D (30 days).", - "example": "P7D" - }, "start-predict-date": { "type": [ "string", @@ -4213,37 +4205,10 @@ "description": "Maximum forecast horizon. Defaults to covering the whole prediction period (which itself defaults to 48 hours).", "example": "PT48H" }, - "forecast-frequency": { - "type": [ - "string", - "null" - ], - "description": "How often to recompute forecasts. Defaults to retrain frequency.", - "example": "PT1H" - }, - "sensor-to-save": { - "type": [ - "integer", - "null" - ], - "description": "Sensor ID where forecasts will be saved; defaults to target sensor.", - "example": 2092 - }, - "max-training-period": { - "type": [ - "string", - "null" - ], - "description": "Maximum duration of the training period. Defaults to 1 year (P1Y).", - "example": "P1Y" - }, "config": { "$ref": "#/components/schemas/TrainPredictPipelineConfig" } }, - "required": [ - "sensor" - ], "additionalProperties": false }, "UserAPIQuerySchema": { From 0f9a4c155655c98f6375aec52193706837baf3fd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 17 Feb 2026 15:21:05 +0100 Subject: [PATCH 045/141] docs: clarify what happens to the source ID if you change the forecaster config Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 8 +++++++- flexmeasures/ui/static/openapi-specs.json | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 7620a276e7..aec0ded9ad 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -517,4 +517,10 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 class ForecastingTriggerSchema(ForecasterParametersSchema): - config = fields.Nested(TrainPredictPipelineConfigSchema(), required=False) + config = fields.Nested( + TrainPredictPipelineConfigSchema(), + required=False, + metadata={ + "description": "Changing any of these will result in a new data source ID." + }, + ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index a400d279a6..5071aa8e4a 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4206,6 +4206,7 @@ "example": "PT48H" }, "config": { + "description": "Changing any of these will result in a new data source ID.", "$ref": "#/components/schemas/TrainPredictPipelineConfig" } }, From e2f0128655a1e1bbf44f1c6cd0f21205743cc244 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 15:19:09 +0100 Subject: [PATCH 046/141] fix: change target_sensor reference from target to sensor Signed-off-by: Mohamed Belhsan Hmida --- .../data/models/forecasting/pipelines/train_predict.py | 4 ++-- flexmeasures/data/tests/test_train_predict_pipeline.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 4ee74c858b..e393fdf60f 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -98,12 +98,12 @@ def run_cycle( target_sensor=self._parameters["sensor"], model_path=os.path.join( self._parameters["model_save_dir"], - f"sensor_{self._parameters['target'].id}-cycle_{counter}-lgbm.pkl", + f"sensor_{self._parameters['sensor'].id}-cycle_{counter}-lgbm.pkl", ), output_path=( os.path.join( self._parameters["output_path"], - f"sensor_{self._parameters['target'].id}-cycle_{counter}.csv", + f"sensor_{self._parameters['sensor'].id}-cycle_{counter}.csv", ) if self._parameters["output_path"] else None diff --git a/flexmeasures/data/tests/test_train_predict_pipeline.py b/flexmeasures/data/tests/test_train_predict_pipeline.py index 629092e947..89d63613e5 100644 --- a/flexmeasures/data/tests/test_train_predict_pipeline.py +++ b/flexmeasures/data/tests/test_train_predict_pipeline.py @@ -197,7 +197,7 @@ def test_train_predict_pipeline( # noqa: C901 dg_params["forecast_frequency"] ) # 1 hour of forecasts is saved over 4 15-minute resolution events - n_events_per_horizon = timedelta(hours=1) / dg_params["target"].event_resolution + n_events_per_horizon = timedelta(hours=1) / dg_params["sensor"].event_resolution n_hourly_horizons = dg_params["max_forecast_horizon"] // timedelta(hours=1) assert ( len(forecasts) == n_cycles * n_hourly_horizons * n_events_per_horizon From fb8a9cb11691318e75bf74572bad679b1ede399a Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 18:53:30 +0100 Subject: [PATCH 047/141] fix: update test case 4 comment and expectations. we expect 4 cycles because of retrain_frequency and predict_period Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/tests/test_forecasting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index fbed7d60d3..cda7a96c64 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -157,7 +157,7 @@ # - predict-period = FM planning horizon # - max-forecast-horizon = predict-period (actual horizons are 48, 36, 24 and 12) # - forecast-frequency = predict-period (NOT capped by retraining-period, no param changes based on config) - # - 1 cycle, 1 belief time + # - 4 cycle, 1 belief time ( { "retrain-frequency": "PT12H", @@ -182,7 +182,7 @@ "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ), - "n_cycles": 1, + "n_cycles": 4, }, ), # Case 5: predict-period = 10 days and max-forecast-horizon = 12 hours From 0914a964443b02dae9a35b3dce499ed65f5167af Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Tue, 17 Feb 2026 23:52:43 +0100 Subject: [PATCH 048/141] fix: update cycle frequency calculation to use retrain_frequency instead of predict_period Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/models/forecasting/pipelines/train_predict.py | 2 +- flexmeasures/data/schemas/forecasting/pipeline.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index e393fdf60f..670cf35835 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -196,7 +196,7 @@ def run( # Move forward to the next cycle one prediction period later cycle_frequency = timedelta( - hours=self._parameters["predict_period_in_hours"] + hours=self._parameters["retrain_frequency"] ) train_end += cycle_frequency predict_start += cycle_frequency diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index aec0ded9ad..2671984630 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -506,6 +506,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 max_training_period=max_training_period, predict_start=predict_start, predict_period_in_hours=predict_period_in_hours, + retrain_frequency=retrain_frequency_in_hours, max_forecast_horizon=max_forecast_horizon, forecast_frequency=forecast_frequency, probabilistic=data["probabilistic"], From 79420d3e48817ec33789ce72e25993bcde02068f Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Wed, 18 Feb 2026 19:02:55 +0100 Subject: [PATCH 049/141] fix: search sensor forecasts (the ones computed directly not via api) by source forecaster type since the source isn't the same as one generated by api. Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index d3e4918b36..b429ff4675 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -103,14 +103,11 @@ def test_trigger_and_fetch_forecasts( assert isinstance(api_forecasts, list) assert len(api_forecasts) > 0 - # Identify which data source wrote these beliefs - data_source = get_data_source_for_job(job, type="forecasting") - # Load only the latest belief per event_start forecasts_df = sensor_1.search_beliefs( event_starts_after=job.meta.get("start_predict_date"), event_ends_before=job.meta.get("end_date") + sensor_1.event_resolution, - source=data_source, + source_types=["forecaster"], most_recent_beliefs_only=True, use_latest_version_per_event=True, ).reset_index() From 8c5731305875b47140ef889fea7aa54ff6ff534f Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Wed, 18 Feb 2026 19:03:11 +0100 Subject: [PATCH 050/141] fix: adjust event end date calculation in forecast belief search to exclude sensor resolution Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index b429ff4675..242a71e691 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -106,7 +106,7 @@ def test_trigger_and_fetch_forecasts( # Load only the latest belief per event_start forecasts_df = sensor_1.search_beliefs( event_starts_after=job.meta.get("start_predict_date"), - event_ends_before=job.meta.get("end_date") + sensor_1.event_resolution, + event_ends_before=job.meta.get("end_date"), source_types=["forecaster"], most_recent_beliefs_only=True, use_latest_version_per_event=True, From 075bf4ec194c9b0fe5774d110484e167875b7c7b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Wed, 18 Feb 2026 19:05:36 +0100 Subject: [PATCH 051/141] refactor: move cycle_frequency variable outside for loop Signed-off-by: Mohamed Belhsan Hmida --- .../data/models/forecasting/pipelines/train_predict.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 670cf35835..ab4244106d 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -157,6 +157,10 @@ def run( logging.info( f"Starting Train-Predict Pipeline to predict for {self._parameters['predict_period_in_hours']} hours." ) + # How much to move forward to the next cycle one prediction period later + cycle_frequency = timedelta( + hours=self._parameters["retrain_frequency"] + ) predict_start = self._parameters["predict_start"] predict_end = predict_start + timedelta( @@ -194,10 +198,6 @@ def run( train_predict_params["target_sensor_id"] = self._parameters["sensor"].id cycles_job_params.append(train_predict_params) - # Move forward to the next cycle one prediction period later - cycle_frequency = timedelta( - hours=self._parameters["retrain_frequency"] - ) train_end += cycle_frequency predict_start += cycle_frequency predict_end += cycle_frequency From 05f1b1c8754dd590cca795353ff6bd28299d170b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Wed, 18 Feb 2026 19:05:54 +0100 Subject: [PATCH 052/141] fix: update predict_end calculation to use cycle_frequency instead of predict_period_in_hours Signed-off-by: Mohamed Belhsan Hmida --- .../data/models/forecasting/pipelines/train_predict.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index ab4244106d..632e25f0ee 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -163,9 +163,8 @@ def run( ) predict_start = self._parameters["predict_start"] - predict_end = predict_start + timedelta( - hours=self._parameters["predict_period_in_hours"] - ) + predict_end = predict_start + cycle_frequency + train_start = predict_start - timedelta( hours=self._parameters["train_period_in_hours"] ) From 651b0691e2d22d4280a3b0b67dc0550836e40276 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Wed, 18 Feb 2026 19:06:40 +0100 Subject: [PATCH 053/141] fix: use default value for probabilistic in ForecasterParametersSchema this fixes issue when we call via api this param default isn't loaded Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 2671984630..cb9d114f40 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -509,7 +509,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 retrain_frequency=retrain_frequency_in_hours, max_forecast_horizon=max_forecast_horizon, forecast_frequency=forecast_frequency, - probabilistic=data["probabilistic"], + probabilistic=data.get("probabilistic", False), sensor_to_save=sensor_to_save, save_belief_time=save_belief_time, n_cycles=int(predict_period // timedelta(hours=retrain_frequency_in_hours)), From d6cbeea50f0b22ac8674d7435d9298ce80cdf694 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Wed, 18 Feb 2026 19:08:14 +0100 Subject: [PATCH 054/141] chore: remove unused import and run pre-commit Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 4 +--- .../data/models/forecasting/pipelines/train_predict.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index 242a71e691..87f8614676 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -2,9 +2,7 @@ import isodate import pytest from flask import url_for -from flexmeasures.data.services.scheduling import ( - get_data_source_for_job, -) + from rq.job import Job from flexmeasures.utils.job_utils import work_on_rq from flexmeasures.api.tests.utils import get_auth_token diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 632e25f0ee..073e0f261e 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -158,9 +158,7 @@ def run( f"Starting Train-Predict Pipeline to predict for {self._parameters['predict_period_in_hours']} hours." ) # How much to move forward to the next cycle one prediction period later - cycle_frequency = timedelta( - hours=self._parameters["retrain_frequency"] - ) + cycle_frequency = timedelta(hours=self._parameters["retrain_frequency"]) predict_start = self._parameters["predict_start"] predict_end = predict_start + cycle_frequency From 95be4f478ee52ba2eb7b43cd89495e6b4592ce5b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 19 Feb 2026 00:00:00 +0100 Subject: [PATCH 055/141] feat(test): update test case to only one day of prediction Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/tests/test_train_predict_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/tests/test_train_predict_pipeline.py b/flexmeasures/data/tests/test_train_predict_pipeline.py index 89d63613e5..974685092e 100644 --- a/flexmeasures/data/tests/test_train_predict_pipeline.py +++ b/flexmeasures/data/tests/test_train_predict_pipeline.py @@ -88,7 +88,7 @@ "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, "start-date": "2025-01-01T00:00+02:00", - "start-predict-date": "2025-01-04T00:00+02:00", + "start-predict-date": "2025-01-08T00:00+02:00", "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", From 4809f26d0af35df8e223defac14ef84412568853 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 19 Feb 2026 00:05:13 +0100 Subject: [PATCH 056/141] fix: add forecast_frequency to test params Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/tests/test_train_predict_pipeline.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/tests/test_train_predict_pipeline.py b/flexmeasures/data/tests/test_train_predict_pipeline.py index 974685092e..1b48da39c9 100644 --- a/flexmeasures/data/tests/test_train_predict_pipeline.py +++ b/flexmeasures/data/tests/test_train_predict_pipeline.py @@ -52,6 +52,7 @@ "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", + "forecast-frequency": "PT24H", "probabilistic": False, }, True, @@ -72,6 +73,7 @@ "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", + "forecast-frequency": "PT24H", "probabilistic": False, }, False, @@ -92,6 +94,7 @@ "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", + "forecast-frequency": "PT24H", "probabilistic": False, }, False, @@ -113,7 +116,7 @@ "start-predict-date": "2025-01-02T00:00+02:00", "retrain-frequency": "P1D", "max-forecast-horizon": "PT1H", - "forecast-frequency": "PT1H", + "forecast-frequency": "PT24H", "probabilistic": False, }, False, From 169d0198f6368091a35524ea33ceafb9742a994c Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 19 Feb 2026 00:05:42 +0100 Subject: [PATCH 057/141] chore: remove old commented out test case Signed-off-by: Mohamed Belhsan Hmida --- .../data/tests/test_train_predict_pipeline.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/flexmeasures/data/tests/test_train_predict_pipeline.py b/flexmeasures/data/tests/test_train_predict_pipeline.py index 1b48da39c9..8210bbdd59 100644 --- a/flexmeasures/data/tests/test_train_predict_pipeline.py +++ b/flexmeasures/data/tests/test_train_predict_pipeline.py @@ -122,24 +122,6 @@ False, None, ), - # ( - # {}, - # { - # "sensor": "solar-sensor", - # "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", - # "output-path": None, - # "start-date": "2025-07-01T00:00+02:00", - # "end-date": "2025-07-12T00:00+02:00", - # "sensor-to-save": 1, - # "start-predict-date": "2025-07-11T17:26+02:00", - # "retrain-frequency": "PT24H", - # "max-forecast-horizon": 24, - # "forecast-frequency": 1, - # "probabilistic": False, - # }, - # False, - # (ValidationError, "Try increasing the --end-date."), - # ) ], ) def test_train_predict_pipeline( # noqa: C901 From 1c4da9711375b189688f4de1834a28084f23d633 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 19 Feb 2026 00:33:45 +0100 Subject: [PATCH 058/141] dev: uncomment out test cases Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/tests/test_forecasting.py | 400 +++++++++--------- 1 file changed, 200 insertions(+), 200 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index cda7a96c64..1d014c2589 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -241,116 +241,116 @@ # Test defaults when only an end date is given # We expect training period of 30 days before predict start and prediction period of 5 days after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief time for forecast) given the forecast frequency equal defaulted to prediction period of 5 days. - # ( - # {"end-date": "2025-01-20T12:00:00+01:00"}, - # { - # "predict-start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ).floor("1h"), - # "start-date": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ).floor("1h") - # - pd.Timedelta( - # days=30 - # ), # default training period 30 days before predict start - # "end-date": pd.Timestamp( - # "2025-01-20T12:00:00+01", - # tz="Europe/Amsterdam", - # ), - # "train-period-in-hours": 720, # from start date to predict start - # "predict-period-in-hours": 120, # from predict start to end date - # "forecast-frequency": pd.Timedelta( - # days=5 - # ), # duration between predict start and end date - # "max-forecast-horizon": pd.Timedelta( - # days=5 - # ), # duration between predict start and end date - # # default values - # "max-training-period": pd.Timedelta(days=365), - # # server now - # "save-belief-time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ), - # "n_cycles": 1, - # }, - # ), + ( + {"end-date": "2025-01-20T12:00:00+01:00"}, + { + "predict-start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ).floor("1h"), + "start-date": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ).floor("1h") + - pd.Timedelta( + days=30 + ), # default training period 30 days before predict start + "end-date": pd.Timestamp( + "2025-01-20T12:00:00+01", + tz="Europe/Amsterdam", + ), + "train-period-in-hours": 720, # from start date to predict start + "predict-period-in-hours": 120, # from predict start to end date + "forecast-frequency": pd.Timedelta( + days=5 + ), # duration between predict start and end date + "max-forecast-horizon": pd.Timedelta( + days=5 + ), # duration between predict start and end date + # default values + "max-training-period": pd.Timedelta(days=365), + # server now + "save-belief-time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ), + "n_cycles": 1, + }, + ), # Test when both start and end dates are given # We expect training period of 26.5 days (636 hours) from the given start date and predict start, prediction period of 108 hours duration from predict start to end date, with predict_start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period - # ( - # { - # "start-date": "2024-12-20T00:00:00+01:00", - # "end-date": "2025-01-20T00:00:00+01:00", - # }, - # { - # "start-date": pd.Timestamp( - # "2024-12-20T00:00:00+01", tz="Europe/Amsterdam" - # ), - # "end-date": pd.Timestamp( - # "2025-01-20T00:00:00+01", tz="Europe/Amsterdam" - # ), - # "predict-start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ).floor("1h"), - # "predict-period-in-hours": 108, # hours from predict start to end date - # "train-period-in-hours": 636, # hours between start date and predict start - # "max-forecast-horizon": pd.Timedelta(days=4) - # + pd.Timedelta(hours=12), # duration between predict start and end date - # "forecast-frequency": pd.Timedelta(days=4) - # + pd.Timedelta(hours=12), # duration between predict start and end date - # # default values - # "max-training-period": pd.Timedelta(days=365), - # # server now - # "save-belief-time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ), - # "n_cycles": 1, - # }, - # ), + ( + { + "start-date": "2024-12-20T00:00:00+01:00", + "end-date": "2025-01-20T00:00:00+01:00", + }, + { + "start-date": pd.Timestamp( + "2024-12-20T00:00:00+01", tz="Europe/Amsterdam" + ), + "end-date": pd.Timestamp( + "2025-01-20T00:00:00+01", tz="Europe/Amsterdam" + ), + "predict-start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ).floor("1h"), + "predict-period-in-hours": 108, # hours from predict start to end date + "train-period-in-hours": 636, # hours between start date and predict start + "max-forecast-horizon": pd.Timedelta(days=4) + + pd.Timedelta(hours=12), # duration between predict start and end date + "forecast-frequency": pd.Timedelta(days=4) + + pd.Timedelta(hours=12), # duration between predict start and end date + # default values + "max-training-period": pd.Timedelta(days=365), + # server now + "save-belief-time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ), + "n_cycles": 1, + }, + ), # Test when only end date is given with a training period # We expect the start date to be computed with respect to now. (training period before now (floored)). # We expect training period of 30 days before predict start and prediction period of 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period - # ( - # { - # "end-date": "2025-01-20T12:00:00+01:00", - # "train-period": "P3D", - # }, - # { - # "end-date": pd.Timestamp( - # "2025-01-20T12:00:00+01", tz="Europe/Amsterdam" - # ), - # "predict-start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ).floor("1h"), - # "start-date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # - pd.Timedelta(days=3), - # "train-period-in-hours": 72, # from start date to predict start - # "predict-period-in-hours": 120, # from predict start to end date - # "max-forecast-horizon": pd.Timedelta( - # days=5 - # ), # duration between predict start and end date - # "forecast-frequency": pd.Timedelta( - # days=5 - # ), # duration between predict start and end date - # # default values - # "max-training-period": pd.Timedelta(days=365), - # # server now - # "save-belief-time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ), - # "n_cycles": 1, - # }, - # ), + ( + { + "end-date": "2025-01-20T12:00:00+01:00", + "train-period": "P3D", + }, + { + "end-date": pd.Timestamp( + "2025-01-20T12:00:00+01", tz="Europe/Amsterdam" + ), + "predict-start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ).floor("1h"), + "start-date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + - pd.Timedelta(days=3), + "train-period-in-hours": 72, # from start date to predict start + "predict-period-in-hours": 120, # from predict start to end date + "max-forecast-horizon": pd.Timedelta( + days=5 + ), # duration between predict start and end date + "forecast-frequency": pd.Timedelta( + days=5 + ), # duration between predict start and end date + # default values + "max-training-period": pd.Timedelta(days=365), + # server now + "save-belief-time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ), + "n_cycles": 1, + }, + ), # Test when only start date is given with a training period # We expect the predict start to be computed with respect to the start date (training period after start date). # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). @@ -391,109 +391,109 @@ # We expect the predict start to be computed with respect to the start date (training period after start date). # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period - # ( - # { - # "start-date": "2024-12-25T00:00:00+01:00", - # "retrain-frequency": "P3D", - # }, - # { - # "start-date": pd.Timestamp( - # "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" - # ), - # "predict-start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ).floor("1h"), - # "end-date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # + pd.Timedelta(days=3), - # "predict-period-in-hours": 72, - # "train-period-in-hours": 516, # from start-date to predict-start - # "max-forecast-horizon": pd.Timedelta( - # days=3 - # ), # duration between predict-start and end-date - # "forecast-frequency": pd.Timedelta( - # days=3 - # ), # duration between predict-start and end-date - # # default values - # "max-training-period": pd.Timedelta(days=365), - # # server now - # "save-belief-time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ), - # "n_cycles": 1, - # }, - # ), + ( + { + "start-date": "2024-12-25T00:00:00+01:00", + "retrain-frequency": "P3D", + }, + { + "start-date": pd.Timestamp( + "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" + ), + "predict-start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ).floor("1h"), + "end-date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + + pd.Timedelta(days=3), + "predict-period-in-hours": 72, + "train-period-in-hours": 516, # from start-date to predict-start + "max-forecast-horizon": pd.Timedelta( + days=3 + ), # duration between predict-start and end-date + "forecast-frequency": pd.Timedelta( + days=3 + ), # duration between predict-start and end-date + # default values + "max-training-period": pd.Timedelta(days=365), + # server now + "save-belief-time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ), + "n_cycles": 1, + }, + ), # Test when only start date is given with both training period 20 days and retrain frequency 3 days # We expect the predict start to be computed with respect to the start date (training period after start date). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period - # ( - # { - # "start-date": "2024-12-01T00:00:00+01:00", - # "train-period": "P20D", - # "retrain-frequency": "P3D", - # }, - # { - # "start-date": pd.Timestamp( - # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - # ), - # "predict-start": pd.Timestamp( - # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - # ) - # + pd.Timedelta(days=20), - # "end-date": pd.Timestamp( - # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - # ) - # + pd.Timedelta(days=23), - # "train-period-in-hours": 480, - # "predict-period-in-hours": 72, - # "max-forecast-horizon": pd.Timedelta(days=3), # predict period duration - # "forecast-frequency": pd.Timedelta(days=3), # predict period duration - # # default values - # "max-training-period": pd.Timedelta(days=365), - # # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency - # "save-belief-time": None, - # }, - # ), + ( + { + "start-date": "2024-12-01T00:00:00+01:00", + "train-period": "P20D", + "retrain-frequency": "P3D", + }, + { + "start-date": pd.Timestamp( + "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + ), + "predict-start": pd.Timestamp( + "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + ) + + pd.Timedelta(days=20), + "end-date": pd.Timestamp( + "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + ) + + pd.Timedelta(days=23), + "train-period-in-hours": 480, + "predict-period-in-hours": 72, + "max-forecast-horizon": pd.Timedelta(days=3), # predict period duration + "forecast-frequency": pd.Timedelta(days=3), # predict period duration + # default values + "max-training-period": pd.Timedelta(days=365), + # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency + "save-belief-time": None, + }, + ), # Test when only end date is given with a prediction period: we expect the train start and predict start to both be computed with respect to the end date. # we expect training period of 30 days before predict_start and prediction period of 3 days after predict_start, with predict_start at server now (floored to hour). # we expect 2 cycles from the retrain frequency and predict period given the end date - # ( - # { - # "end-date": "2025-01-21T12:00:00+01:00", - # "retrain-frequency": "P3D", - # }, - # { - # "end-date": pd.Timestamp( - # "2025-01-21T12:00:00+01", tz="Europe/Amsterdam" - # ), - # "predict-start": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ), - # "start-date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # - pd.Timedelta(days=30), - # "predict-period-in-hours": 72, - # "train-period-in-hours": 720, - # "max-forecast-horizon": pd.Timedelta( - # days=3 - # ), # duration between predict start and end date (retrain frequency) - # "forecast-frequency": pd.Timedelta( - # days=3 - # ), # duration between predict start and end date (retrain frequency) - # # default values - # "max-training-period": pd.Timedelta(days=365), - # # server now - # "save-belief-time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", - # tz="Europe/Amsterdam", - # ), - # "n_cycles": 2, # we expect 2 cycles from the retrain frequency and predict period given the end date - # }, - # ), + ( + { + "end-date": "2025-01-21T12:00:00+01:00", + "retrain-frequency": "P3D", + }, + { + "end-date": pd.Timestamp( + "2025-01-21T12:00:00+01", tz="Europe/Amsterdam" + ), + "predict-start": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ), + "start-date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + - pd.Timedelta(days=30), + "predict-period-in-hours": 72, + "train-period-in-hours": 720, + "max-forecast-horizon": pd.Timedelta( + days=3 + ), # duration between predict start and end date (retrain frequency) + "forecast-frequency": pd.Timedelta( + days=3 + ), # duration between predict start and end date (retrain frequency) + # default values + "max-training-period": pd.Timedelta(days=365), + # server now + "save-belief-time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", + ), + "n_cycles": 2, # we expect 2 cycles from the retrain frequency and predict period given the end date + }, + ), ], ) def test_timing_parameters_of_forecaster_parameters_schema( From 6d1d2ac106da9607b9ac4e2f3564d1467a2fffb3 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Thu, 19 Feb 2026 12:15:18 +0100 Subject: [PATCH 059/141] chore: remove default value for probabilistic when calling with get Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index cb9d114f40..53b82f0566 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -509,7 +509,7 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 retrain_frequency=retrain_frequency_in_hours, max_forecast_horizon=max_forecast_horizon, forecast_frequency=forecast_frequency, - probabilistic=data.get("probabilistic", False), + probabilistic=data.get("probabilistic"), sensor_to_save=sensor_to_save, save_belief_time=save_belief_time, n_cycles=int(predict_period // timedelta(hours=retrain_frequency_in_hours)), From 243f374e7a9e7faf63ad3b48c7f5de5a5364d554 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 11:06:02 +0100 Subject: [PATCH 060/141] docs: update test case comment Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/tests/test_forecasting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 1d014c2589..1156c53de2 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -157,7 +157,7 @@ # - predict-period = FM planning horizon # - max-forecast-horizon = predict-period (actual horizons are 48, 36, 24 and 12) # - forecast-frequency = predict-period (NOT capped by retraining-period, no param changes based on config) - # - 4 cycle, 1 belief time + # - 4 cycle, 4 belief times ( { "retrain-frequency": "PT12H", From ce811ad57ef384d6eafb7bf627ce9b5632fd1c20 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 11:21:01 +0100 Subject: [PATCH 061/141] feat: calculate pred start date from end date and duration Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 53b82f0566..4db4b5ee33 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -376,8 +376,10 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 field_name="max_training_period", ) - @post_load - def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 + @post_load(pass_original=True) + def resolve_config( + self, data: dict, original_data: dict | None = None, **kwargs + ) -> dict: # noqa: C901 """Resolve timing parameters, using sensible defaults and choices. Defaults: @@ -398,7 +400,14 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 now = server_now() floored_now = floor_to_resolution(now, resolution) - predict_start = data.get("start_predict_date") or floored_now + if data.get("start_predict_date") is None: + if original_data.get("duration") and data.get("end_date") is not None: + predict_start = data["end_date"] - data["duration"] + else: + predict_start = floored_now + else: + predict_start = data["start_predict_date"] + save_belief_time = ( now if data.get("start_predict_date") is None else predict_start ) From 10fdb652aa88e054947a0982074d3decbc124866 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 11:22:04 +0100 Subject: [PATCH 062/141] feat: remove planning horizon from forecast frequency calculation and default retrain_frequency to predict_period Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 4db4b5ee33..df8fa2c14b 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -470,14 +470,13 @@ def resolve_config( if forecast_frequency is None: forecast_frequency = min( - planning_horizon, max_forecast_horizon, predict_period, ) if data.get("retrain_frequency") is None: if data.get("max_forecast_horizon") is None: - retrain_frequency = planning_horizon + retrain_frequency = predict_period # to not have multiple cycles if max_forecast_horizon is not set, as it defaults to predict_period else: # If retrain_freq <= forecast-frequency, we enforce retrain_freq = forecast-frequency retrain_frequency = max(planning_horizon, forecast_frequency) From 5cbc5f4f9086c1e47ec2ceb61910c2d6c60116b8 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 11:22:28 +0100 Subject: [PATCH 063/141] fix(tests): updates test cases Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/tests/test_forecasting.py | 157 +++++++++--------- 1 file changed, 77 insertions(+), 80 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 1156c53de2..19a97021f6 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -84,7 +84,7 @@ "n_cycles": 1, }, ), - # Case 2: max-forecast-horizon = 12 hours + # Case 2: max-forecast-horizon = 12 hours # here we have issue that predict period is defaulted to 48 hours, but max-forecast-horizon is set to 12 hours, which should be less than or equal to predict-period # # User expects to get forecasts for the next 12 hours from a single viewpoint (same as case 1). # Specifically, we expect: @@ -92,31 +92,31 @@ # - forecast-frequency = max-forecast-horizon = 12 hours # - retraining-period = FM planning horizon # - 1 cycle, 1 belief time - # ( - # {"max-forecast-horizon": "PT12H"}, - # { - # "predict_start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h"), - # "start_date": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h") - # - pd.Timedelta(days=30), - # "train_period_in_hours": 720, - # "predict_period_in_hours": 12, - # "max_forecast_horizon": pd.Timedelta(hours=12), - # "forecast_frequency": pd.Timedelta(hours=12), - # "end_date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # + pd.Timedelta(hours=12), - # "max_training_period": pd.Timedelta(days=365), - # "save_belief_time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ), - # "n_cycles": 1, - # }, - # ), + ( + {"max-forecast-horizon": "PT12H"}, + { + "predict_start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h"), + "start_date": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h") + - pd.Timedelta(days=30), + "end_date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + + pd.Timedelta(hours=48), + "train_period_in_hours": 720, + "predict_period_in_hours": 48, + "max_forecast_horizon": pd.Timedelta(hours=12), + "forecast_frequency": pd.Timedelta(hours=12), + "max_training_period": pd.Timedelta(days=365), + "save_belief_time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ), + "n_cycles": 1, + }, + ), # Case 3: forecast-frequency = 12 hours # # User expects to get forecasts for the default FM planning horizon from a new viewpoint every 12 hours. @@ -191,34 +191,34 @@ # - forecast-frequency = max-forecast-horizon = 12 hours # - retraining-frequency = FM planning horizon # - 5 cycles, 20 belief times - # ( - # { - # "duration": "P10D", - # "max-forecast-horizon": "PT12H", - # }, - # { - # "predict_start": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h"), - # "start_date": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ).floor("1h") - # - pd.Timedelta(days=30), - # "train_period_in_hours": 720, - # "predict_period_in_hours": 240, - # "max_forecast_horizon": pd.Timedelta(hours=12), - # "forecast_frequency": pd.Timedelta(hours=12), - # "end_date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # + pd.Timedelta(days=10), - # "max_training_period": pd.Timedelta(days=365), - # "save_belief_time": pd.Timestamp( - # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - # ), - # "n_cycles": 5, - # }, - # ), + ( + { + "duration": "P10D", + "max-forecast-horizon": "PT12H", + }, + { + "predict_start": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h"), + "start_date": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ).floor("1h") + - pd.Timedelta(days=30), + "train_period_in_hours": 720, + "predict_period_in_hours": 240, + "max_forecast_horizon": pd.Timedelta(hours=12), + "forecast_frequency": pd.Timedelta(hours=12), + "end_date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + + pd.Timedelta(days=10), + "max_training_period": pd.Timedelta(days=365), + "save_belief_time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + ), + "n_cycles": 5, + }, + ), # Case 6: predict-period = 12 hours and max-forecast-horizon = 10 days # # User expects that FM complains: the max-forecast-horizon should be lower than the predict-period @@ -263,7 +263,7 @@ "predict-period-in-hours": 120, # from predict start to end date "forecast-frequency": pd.Timedelta( days=5 - ), # duration between predict start and end date + ), # default forecast frequency "max-forecast-horizon": pd.Timedelta( days=5 ), # duration between predict start and end date @@ -279,7 +279,7 @@ ), # Test when both start and end dates are given # We expect training period of 26.5 days (636 hours) from the given start date and predict start, prediction period of 108 hours duration from predict start to end date, with predict_start at server now (floored to hour). - # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period + # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period. how many belief times we expect? we expect 1 belief time for the forecast, as the forecast frequency defaults to the prediction period, and the prediction period is 108 hours, which is longer than the default forecast frequency of 48 hours, so we should not have multiple cycles. ( { "start-date": "2024-12-20T00:00:00+01:00", @@ -298,11 +298,8 @@ ).floor("1h"), "predict-period-in-hours": 108, # hours from predict start to end date "train-period-in-hours": 636, # hours between start date and predict start - "max-forecast-horizon": pd.Timedelta(days=4) - + pd.Timedelta(hours=12), # duration between predict start and end date - "forecast-frequency": pd.Timedelta(days=4) - + pd.Timedelta(hours=12), # duration between predict start and end date - # default values + "max-forecast-horizon": pd.Timedelta(hours=108), + "forecast-frequency": pd.Timedelta(hours=48), # default forecast frequency "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -338,10 +335,10 @@ "max-forecast-horizon": pd.Timedelta( days=5 ), # duration between predict start and end date - "forecast-frequency": pd.Timedelta( - days=5 - ), # duration between predict start and end date # default values + "forecast-frequency": pd.Timedelta( + days=2 + ), "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -387,14 +384,14 @@ "n_cycles": 1, }, ), - # Test when only start date is given with a retrain frequency (prediction period) + # Test when only start date is given with a duration (prediction period) # We expect the predict start to be computed with respect to the start date (training period after start date). # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period ( { "start-date": "2024-12-25T00:00:00+01:00", - "retrain-frequency": "P3D", + "duration": "P3D", }, { "start-date": pd.Timestamp( @@ -413,10 +410,10 @@ "max-forecast-horizon": pd.Timedelta( days=3 ), # duration between predict-start and end-date - "forecast-frequency": pd.Timedelta( - days=3 - ), # duration between predict-start and end-date # default values + "forecast-frequency": pd.Timedelta( + days=2 + ), "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -426,14 +423,14 @@ "n_cycles": 1, }, ), - # Test when only start date is given with both training period 20 days and retrain frequency 3 days + # Test when only start date is given with both training period 20 days and prediction period 3 days # We expect the predict start to be computed with respect to the start date (training period after start date). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period ( { "start-date": "2024-12-01T00:00:00+01:00", "train-period": "P20D", - "retrain-frequency": "P3D", + "duration": "P3D", }, { "start-date": pd.Timestamp( @@ -449,9 +446,9 @@ + pd.Timedelta(days=23), "train-period-in-hours": 480, "predict-period-in-hours": 72, - "max-forecast-horizon": pd.Timedelta(days=3), # predict period duration - "forecast-frequency": pd.Timedelta(days=3), # predict period duration + "max-forecast-horizon": pd.Timedelta(days=3), # defaults to prediction period (duration) # default values + "forecast-frequency": pd.Timedelta(days=2), "max-training-period": pd.Timedelta(days=365), # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency "save-belief-time": None, @@ -476,15 +473,15 @@ "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ) - pd.Timedelta(days=30), - "predict-period-in-hours": 72, + "predict-period-in-hours": 144, # from predict start to end date "train-period-in-hours": 720, "max-forecast-horizon": pd.Timedelta( - days=3 - ), # duration between predict start and end date (retrain frequency) - "forecast-frequency": pd.Timedelta( - days=3 - ), # duration between predict start and end date (retrain frequency) + days=6 + ), # duration between predict start and end date # default values + "forecast-frequency": pd.Timedelta( + days=2 + ), "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -519,7 +516,7 @@ def test_timing_parameters_of_forecaster_parameters_schema( **timing_input, } ) - + # breakpoint() for k, v in expected_timing_output.items(): # Convert kebab-case key to snake_case to match data dictionary keys returned by schema snake_key = kebab_to_snake(k) From 02331b82b9d0aecc9bde32766232819be1ff7ba0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 11:26:46 +0100 Subject: [PATCH 064/141] docs: annotate case 7 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 19a97021f6..f0f7592a1c 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -238,9 +238,14 @@ } ), ), + # Case 7: end-date = almost 5 days after now + # + # User expects to get forecasts for the next 5 days (from server now floored to 1 hour) with a default 30-day training period # Test defaults when only an end date is given - # We expect training period of 30 days before predict start and prediction period of 5 days after predict start, with predict start at server now (floored to hour). - # 1 cycle expected (1 belief time for forecast) given the forecast frequency equal defaulted to prediction period of 5 days. + # - predict-period = 5 days + # - forecast-frequency = predict-period + # - retraining-frequency = FM planning horizon + # - 1 cycle, 1 belief time ( {"end-date": "2025-01-20T12:00:00+01:00"}, { From f6ca2b75d16066206e06c791004fc0ddbaa68dc9 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 11:29:36 +0100 Subject: [PATCH 065/141] fix(test): update forecast_frequency in tests Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/tests/test_forecasting.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index f0f7592a1c..4da13e26aa 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -304,7 +304,7 @@ "predict-period-in-hours": 108, # hours from predict start to end date "train-period-in-hours": 636, # hours between start date and predict start "max-forecast-horizon": pd.Timedelta(hours=108), - "forecast-frequency": pd.Timedelta(hours=48), # default forecast frequency + "forecast-frequency": pd.Timedelta(hours=108), "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -340,10 +340,10 @@ "max-forecast-horizon": pd.Timedelta( days=5 ), # duration between predict start and end date - # default values "forecast-frequency": pd.Timedelta( - days=2 + days=5 ), + # default values "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -415,10 +415,10 @@ "max-forecast-horizon": pd.Timedelta( days=3 ), # duration between predict-start and end-date - # default values "forecast-frequency": pd.Timedelta( - days=2 + days=3 ), + # default values "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -452,8 +452,8 @@ "train-period-in-hours": 480, "predict-period-in-hours": 72, "max-forecast-horizon": pd.Timedelta(days=3), # defaults to prediction period (duration) + "forecast-frequency": pd.Timedelta(days=3), # default values - "forecast-frequency": pd.Timedelta(days=2), "max-training-period": pd.Timedelta(days=365), # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency "save-belief-time": None, @@ -483,10 +483,10 @@ "max-forecast-horizon": pd.Timedelta( days=6 ), # duration between predict start and end date - # default values "forecast-frequency": pd.Timedelta( - days=2 + hours=144 ), + # default values "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( From 0a64291b5ca1435490140e0cb7e273ed0ac68562 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 11:31:21 +0100 Subject: [PATCH 066/141] docs: annotate case 8 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index f0f7592a1c..56a1dbd606 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -241,11 +241,11 @@ # Case 7: end-date = almost 5 days after now # # User expects to get forecasts for the next 5 days (from server now floored to 1 hour) with a default 30-day training period - # Test defaults when only an end date is given # - predict-period = 5 days # - forecast-frequency = predict-period # - retraining-frequency = FM planning horizon # - 1 cycle, 1 belief time + # - training-period = 30 days ( {"end-date": "2025-01-20T12:00:00+01:00"}, { @@ -282,9 +282,14 @@ "n_cycles": 1, }, ), - # Test when both start and end dates are given - # We expect training period of 26.5 days (636 hours) from the given start date and predict start, prediction period of 108 hours duration from predict start to end date, with predict_start at server now (floored to hour). - # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period. how many belief times we expect? we expect 1 belief time for the forecast, as the forecast frequency defaults to the prediction period, and the prediction period is 108 hours, which is longer than the default forecast frequency of 48 hours, so we should not have multiple cycles. + # Case 8: end-date = almost 4.5 days after now, start-date is 26.5 days before now + # + # User expects to get forecasts for the next 4.5 days (from server now floored to 1 hour) with a custom 636-hour training period + # - predict-period = 108 hours + # - forecast-frequency = predict-period + # - retraining-frequency = FM planning horizon + # - 1 cycle, 1 belief time + # - training-period = 636 hours ( { "start-date": "2024-12-20T00:00:00+01:00", From 06773c3a0e5d6c4bec7bfc2d8a41401878ba5025 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 11:32:24 +0100 Subject: [PATCH 067/141] docs: enumerate remaining test cases Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 1cefc02a2e..f6fea71bad 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -319,7 +319,7 @@ "n_cycles": 1, }, ), - # Test when only end date is given with a training period + # Case 9: Test when only end date is given with a training period # We expect the start date to be computed with respect to now. (training period before now (floored)). # We expect training period of 30 days before predict start and prediction period of 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period @@ -358,7 +358,7 @@ "n_cycles": 1, }, ), - # Test when only start date is given with a training period + # Case 10: Test when only start date is given with a training period # We expect the predict start to be computed with respect to the start date (training period after start date). # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period @@ -394,7 +394,7 @@ "n_cycles": 1, }, ), - # Test when only start date is given with a duration (prediction period) + # Case 11: Test when only start date is given with a duration (prediction period) # We expect the predict start to be computed with respect to the start date (training period after start date). # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period @@ -433,7 +433,7 @@ "n_cycles": 1, }, ), - # Test when only start date is given with both training period 20 days and prediction period 3 days + # Case 12: Test when only start date is given with both training period 20 days and prediction period 3 days # We expect the predict start to be computed with respect to the start date (training period after start date). # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period ( @@ -464,7 +464,7 @@ "save-belief-time": None, }, ), - # Test when only end date is given with a prediction period: we expect the train start and predict start to both be computed with respect to the end date. + # Case 13: Test when only end date is given with a prediction period: we expect the train start and predict start to both be computed with respect to the end date. # we expect training period of 30 days before predict_start and prediction period of 3 days after predict_start, with predict_start at server now (floored to hour). # we expect 2 cycles from the retrain frequency and predict period given the end date ( From 3a9790d5c9b11d206a2dae1fb237c84cdd9ae858 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 11:44:35 +0100 Subject: [PATCH 068/141] fix(tests): add start-predict-date to case 3 Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/tests/test_forecasting.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index f6fea71bad..d0b0385394 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -40,9 +40,10 @@ "predict-period-in-hours": 48, "max-forecast-horizon": pd.Timedelta(days=2), "train-period-in-hours": 720, + # default values "max-training-period": pd.Timedelta(days=365), "forecast-frequency": pd.Timedelta(days=2), - # server now + # server now for saving belief time of forecasts when run "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", @@ -126,11 +127,13 @@ # - retraining-period = FM planning horizon # - 1 cycle, 4 belief times ( - {"forecast-frequency": "PT12H"}, + { + "start-predict-date": "2025-01-15T12:00:00+01:00", + "forecast-frequency": "PT12H" + }, { "predict_start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h"), + "2025-01-15T12:00:00.000+01", tz="Europe/Amsterdam"), "start_date": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ).floor("1h") @@ -145,7 +148,7 @@ + pd.Timedelta(hours=48), "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + "2025-01-15T12:00:00.00+01", tz="Europe/Amsterdam" ), "n_cycles": 1, }, From 3d9410a159ea1a3895033e7a76c2de972d38e617 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 11:45:13 +0100 Subject: [PATCH 069/141] docs: clarify case 0 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index f6fea71bad..82e7180b49 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -39,10 +39,10 @@ # these are set by the schema defaults "predict-period-in-hours": 48, "max-forecast-horizon": pd.Timedelta(days=2), - "train-period-in-hours": 720, + "train-period-in-hours": 24 * 30, "max-training-period": pd.Timedelta(days=365), "forecast-frequency": pd.Timedelta(days=2), - # server now + # the one belief time corresponds to server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", From 632cd84784337998a95735301c135bb4ccf309ec Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 11:52:04 +0100 Subject: [PATCH 070/141] docs: add comment Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index df8fa2c14b..51981554d7 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -389,7 +389,7 @@ def resolve_config( Choices: - If max-forecast-horizon <= predict-period, we raise a ValidationError due to incomplete coverage - - retraining-frequency becomes the maximum of (FM planning horizon and forecast-frequency) + - retraining-frequency becomes the maximum of (FM planning horizon and forecast-frequency), this is capped by the predict-period. """ target_sensor = data["sensor"] From 7bc71309a8ef53409df71c9748ea45614aed92d2 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 11:52:54 +0100 Subject: [PATCH 071/141] docs: check retraining-frequency in case 1 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 1 - flexmeasures/data/schemas/tests/test_forecasting.py | 6 ++++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index df8fa2c14b..75564b94c6 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -417,7 +417,6 @@ def resolve_config( and data.get("train_period") and data.get("start_date") ): - predict_start = data["start_date"] + data["train_period"] save_belief_time = None diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index cdaa919f7b..640b25c195 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -56,7 +56,7 @@ # Specifically, we expect: # - max-forecast-horizon = predict-period = 12 hours # - forecast-frequency = predict-period = 12 hours - # - (config) retraining-frequency = FM planning horizon + # - (config) retraining-frequency = FM planning horizon, but capped by predict-period, so 12 hours # - 1 cycle, 1 belief time # - training-period = 30 days ( @@ -69,7 +69,7 @@ "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ).floor("1h") - pd.Timedelta(days=30), - "train_period_in_hours": 720, + "train_period_in_hours": 24 * 30, "predict_period_in_hours": 12, "max_forecast_horizon": pd.Timedelta(hours=12), "forecast_frequency": pd.Timedelta(hours=12), @@ -77,6 +77,7 @@ "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ) + pd.Timedelta(hours=12), + "retrain_frequency": 12, "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" @@ -118,6 +119,7 @@ }, ), # Case 3: forecast-frequency = 12 hours + # todo: add to description that this should really be used in combination with the predict-start field # # User expects to get forecasts for the default FM planning horizon from a new viewpoint every 12 hours. # Specifically, we expect: From 0fcc6cd3ff528a05a639debbdfed514d3ad0e835 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 11:56:27 +0100 Subject: [PATCH 072/141] docs: enumerate defaults and choices Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 75564b94c6..051af76e6b 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -383,13 +383,13 @@ def resolve_config( """Resolve timing parameters, using sensible defaults and choices. Defaults: - - predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) - - max-forecast-horizon defaults to the predict-period - - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) + 1. predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) + 2. max-forecast-horizon defaults to the predict-period + 3. forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) Choices: - - If max-forecast-horizon <= predict-period, we raise a ValidationError due to incomplete coverage - - retraining-frequency becomes the maximum of (FM planning horizon and forecast-frequency) + 1. If max-forecast-horizon < predict-period, we raise a ValidationError due to incomplete coverage + 2. retraining-frequency becomes the maximum of (FM planning horizon and forecast-frequency) """ target_sensor = data["sensor"] From a03452c53d6c28d40a667e3c2c6fb69ec96aa06b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 12:17:31 +0100 Subject: [PATCH 073/141] docs: add docstring Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 51981554d7..e1336f9132 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -383,7 +383,7 @@ def resolve_config( """Resolve timing parameters, using sensible defaults and choices. Defaults: - - predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) + - predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) only if there is one cycle. - max-forecast-horizon defaults to the predict-period - forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) @@ -462,11 +462,13 @@ def resolve_config( if max_forecast_horizon is None: max_forecast_horizon = predict_period - if max_forecast_horizon > predict_period: + elif max_forecast_horizon > predict_period: raise ValidationError( "max-forecast-horizon must be less than or equal to predict-period", field_name="max_forecast_horizon", ) + elif max_forecast_horizon < predict_period and (predict_period // data.get("retrain_frequency")) <= 1: + predict_period = max_forecast_horizon if forecast_frequency is None: forecast_frequency = min( From f942e5c22cb04d7961b922e3cbb97f3a492c35e5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 12:21:46 +0100 Subject: [PATCH 074/141] fix: case 2 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 5 +++-- flexmeasures/data/schemas/tests/test_forecasting.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 051af76e6b..f345952d21 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -460,12 +460,13 @@ def resolve_config( if max_forecast_horizon is None: max_forecast_horizon = predict_period - - if max_forecast_horizon > predict_period: + elif max_forecast_horizon > predict_period: raise ValidationError( "max-forecast-horizon must be less than or equal to predict-period", field_name="max_forecast_horizon", ) + elif max_forecast_horizon < predict_period: + predict_period = max_forecast_horizon if forecast_frequency is None: forecast_frequency = min( diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 640b25c195..bcab7d9c43 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -93,6 +93,7 @@ # - forecast-frequency = max-forecast-horizon = 12 hours # - retraining-period = FM planning horizon # - 1 cycle, 1 belief time + # These expectations are encoded in default 1 of ForecasterParametersSchema.resolve_config ( {"max-forecast-horizon": "PT12H"}, { @@ -107,8 +108,8 @@ "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ) + pd.Timedelta(hours=48), - "train_period_in_hours": 720, - "predict_period_in_hours": 48, + "train_period_in_hours": 30 * 24, + "predict_period_in_hours": 12, "max_forecast_horizon": pd.Timedelta(hours=12), "forecast_frequency": pd.Timedelta(hours=12), "max_training_period": pd.Timedelta(days=365), From c472fff97bdb920040fb69c437dd31b89737f871 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 12:23:14 +0100 Subject: [PATCH 075/141] docs: explain case 3 slightly better Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index bcab7d9c43..14f6eff08b 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -140,7 +140,7 @@ "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ).floor("1h") - pd.Timedelta(days=30), - "train_period_in_hours": 720, + "train_period_in_hours": 30 * 24, "predict_period_in_hours": 48, "max_forecast_horizon": pd.Timedelta(hours=48), "forecast_frequency": pd.Timedelta(hours=12), @@ -149,6 +149,7 @@ ) + pd.Timedelta(hours=48), "max_training_period": pd.Timedelta(days=365), + # this is the first belief time of the four belief times "save_belief_time": pd.Timestamp( "2025-01-15T12:00:00.00+01", tz="Europe/Amsterdam" ), From bc38e7a31218c3feaa3f7f3b9e236253381b5040 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:43:18 +0100 Subject: [PATCH 076/141] feat: set load_default for the retrain-frequency and make it independent of any parameters, because it will be moved to the config, and we don't want to let changing parameters lead to new data source IDs Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index f345952d21..2cf9dae674 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -220,7 +220,7 @@ class ForecasterParametersSchema(Schema): ) retrain_frequency = DurationField( data_key="retrain-frequency", - required=False, + load_default=PlanningDurationField.load_default, allow_none=True, metadata={ "description": "Frequency of retraining/prediction cycle (ISO 8601 duration). Defaults to prediction window length if not set.", @@ -474,16 +474,7 @@ def resolve_config( predict_period, ) - if data.get("retrain_frequency") is None: - if data.get("max_forecast_horizon") is None: - retrain_frequency = predict_period # to not have multiple cycles if max_forecast_horizon is not set, as it defaults to predict_period - else: - # If retrain_freq <= forecast-frequency, we enforce retrain_freq = forecast-frequency - retrain_frequency = max(planning_horizon, forecast_frequency) - else: - retrain_frequency = data["retrain_frequency"] - if retrain_frequency > predict_period: - retrain_frequency = predict_period + retrain_frequency = data["retrain_frequency"] retrain_frequency_in_hours = int(retrain_frequency.total_seconds() / 3600) predict_period_in_hours = int(predict_period.total_seconds() / 3600) From 64279518adcaec1b87b8d0bf9f6cacee53b1b2f4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:46:38 +0100 Subject: [PATCH 077/141] feat: base the number of cycles on the retrain-frequency and the forecast-frequency, whichever is larger, and ensure there is always at least 1 cycle Signed-off-by: F.N. Claessen --- .../data/models/forecasting/pipelines/train_predict.py | 10 ++++++---- flexmeasures/data/schemas/forecasting/pipeline.py | 10 +++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 073e0f261e..d6468bae94 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -158,7 +158,10 @@ def run( f"Starting Train-Predict Pipeline to predict for {self._parameters['predict_period_in_hours']} hours." ) # How much to move forward to the next cycle one prediction period later - cycle_frequency = timedelta(hours=self._parameters["retrain_frequency"]) + cycle_frequency = max( + timedelta(hours=self._parameters["retrain_frequency"]), + self._parameters["forecast_frequency"], + ) predict_start = self._parameters["predict_start"] predict_end = predict_start + cycle_frequency @@ -167,7 +170,6 @@ def run( hours=self._parameters["train_period_in_hours"] ) train_end = predict_start - counter = 0 sensor_resolution = self._parameters["sensor"].event_resolution multiplier = int( @@ -176,8 +178,8 @@ def run( cumulative_cycles_runtime = 0 # To track the cumulative runtime of TrainPredictPipeline cycles when not running as a job. cycles_job_params = [] - while predict_end <= self._parameters["end_date"]: - counter += 1 + for counter in range(self._parameters["n_cycles"]): + predict_end = min(predict_end, self._parameters["end_date"]) train_predict_params = { "train_start": train_start, diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 2cf9dae674..34eccc99c5 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -495,6 +495,14 @@ def resolve_config( # Read default from schema model_save_dir = self.fields["model_save_dir"].load_default + n_cycles = max( + int( + predict_period + // max(timedelta(hours=retrain_frequency_in_hours), forecast_frequency), + ), + 1, + ) + return dict( sensor=target_sensor, model_save_dir=model_save_dir, @@ -511,7 +519,7 @@ def resolve_config( probabilistic=data.get("probabilistic"), sensor_to_save=sensor_to_save, save_belief_time=save_belief_time, - n_cycles=int(predict_period // timedelta(hours=retrain_frequency_in_hours)), + n_cycles=n_cycles, ) From 1322ce0aced242febc9e71af409a9b0adfd7b254 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:47:37 +0100 Subject: [PATCH 078/141] refactor: // guarantees an int already Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 34eccc99c5..3d8a5fcc75 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -496,10 +496,8 @@ def resolve_config( model_save_dir = self.fields["model_save_dir"].load_default n_cycles = max( - int( - predict_period - // max(timedelta(hours=retrain_frequency_in_hours), forecast_frequency), - ), + predict_period + // max(timedelta(hours=retrain_frequency_in_hours), forecast_frequency), 1, ) From 98a8274391e6a6802712d20fb98366c2085296a7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:48:29 +0100 Subject: [PATCH 079/141] delete: validator no longer appropriate Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 3d8a5fcc75..b7a696ae33 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -361,13 +361,6 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 f"forecast-frequency must be a multiple of the sensor resolution ({sensor.event_resolution})" ) - if retrain_frequency is not None and forecast_frequency is not None: - if retrain_frequency % forecast_frequency != timedelta(0): - raise ValidationError( - "retrain-frequency must be a multiple of forecast-frequency", - field_name="retrain_frequency", - ) - if isinstance(max_training_period, Duration): # DurationField only returns Duration when years/months are present raise ValidationError( From f1f085d8e04034c8ed6cc0a7f940bb11e3d436c1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:50:15 +0100 Subject: [PATCH 080/141] feat: raise in case of explicitly setting inconsistent variables that would result in incomplete coverage for the prediction window Signed-off-by: F.N. Claessen --- .../data/schemas/forecasting/pipeline.py | 11 ++++++ .../data/schemas/tests/test_forecasting.py | 34 +++++-------------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index b7a696ae33..0516113470 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -451,6 +451,17 @@ def resolve_config( max_forecast_horizon = data.get("max_forecast_horizon") + # Check for inconsistent parameters explicitly set + if ( + "max-forecast-horizon" in original_data + and "duration" in original_data + and max_forecast_horizon < predict_period + ): + raise ValidationError( + "This combination of parameters will not yield forecasts for the entire prediction window.", + field_name="max_forecast_horizon", + ) + if max_forecast_horizon is None: max_forecast_horizon = predict_period elif max_forecast_horizon > predict_period: diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 14f6eff08b..61566e84eb 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -193,37 +193,19 @@ ), # Case 5: predict-period = 10 days and max-forecast-horizon = 12 hours # - # User expects to get forecasts for the next 10 days from a new viewpoint every 12 hours. - # - forecast-frequency = max-forecast-horizon = 12 hours - # - retraining-frequency = FM planning horizon - # - 5 cycles, 20 belief times + # User expects to get a ValidationError for having set parameters that won't give complete coverage of the predict-period. ( { "duration": "P10D", "max-forecast-horizon": "PT12H", }, - { - "predict_start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h"), - "start_date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), - "train_period_in_hours": 720, - "predict_period_in_hours": 240, - "max_forecast_horizon": pd.Timedelta(hours=12), - "forecast_frequency": pd.Timedelta(hours=12), - "end_date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=10), - "max_training_period": pd.Timedelta(days=365), - "save_belief_time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ), - "n_cycles": 5, - }, + ValidationError( + { + "max_forecast_horizon": [ + "This combination of parameters will not yield forecasts for the entire prediction window." + ] + } + ), ), # Case 6: predict-period = 12 hours and max-forecast-horizon = 10 days # From 11f89e179e7704993ab4071885da89adbe08d3a4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:52:42 +0100 Subject: [PATCH 081/141] feat: check retrain-frequency explicitly Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 61566e84eb..70c07d161d 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -40,6 +40,7 @@ "predict-period-in-hours": 48, "max-forecast-horizon": pd.Timedelta(days=2), "train-period-in-hours": 24 * 30, + "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), "forecast-frequency": pd.Timedelta(days=2), # the one belief time corresponds to server now @@ -77,7 +78,7 @@ "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ) + pd.Timedelta(hours=12), - "retrain_frequency": 12, + "retrain_frequency": 2 * 24, "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" @@ -112,6 +113,7 @@ "predict_period_in_hours": 12, "max_forecast_horizon": pd.Timedelta(hours=12), "forecast_frequency": pd.Timedelta(hours=12), + "retrain_frequency": 2 * 24, "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" @@ -149,6 +151,7 @@ ) + pd.Timedelta(hours=48), "max_training_period": pd.Timedelta(days=365), + "retrain-frequency": 2 * 24, # this is the first belief time of the four belief times "save_belief_time": pd.Timestamp( "2025-01-15T12:00:00.00+01", tz="Europe/Amsterdam" @@ -184,6 +187,7 @@ "end_date": pd.Timestamp( "2025-01-17T12:00:00+01", tz="Europe/Amsterdam" ), + "retrain-frequency": 12, "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" @@ -261,6 +265,7 @@ days=5 ), # duration between predict start and end date # default values + "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -298,6 +303,7 @@ "train-period-in-hours": 636, # hours between start date and predict start "max-forecast-horizon": pd.Timedelta(hours=108), "forecast-frequency": pd.Timedelta(hours=108), + "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -337,6 +343,7 @@ days=5 ), # default values + "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -376,6 +383,7 @@ ), # duration between predict start and end date # default values "predict-period-in-hours": 48, + "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency "save-belief-time": None, @@ -412,6 +420,7 @@ days=3 ), # default values + "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( @@ -447,6 +456,7 @@ "max-forecast-horizon": pd.Timedelta(days=3), # defaults to prediction period (duration) "forecast-frequency": pd.Timedelta(days=3), # default values + "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency "save-belief-time": None, @@ -481,6 +491,7 @@ ), # default values "max-training-period": pd.Timedelta(days=365), + "retrain-frequency": 2 * 24, # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", From 784df43fb1c3b84ebe4b33eb1896de616cd42cc4 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:53:53 +0100 Subject: [PATCH 082/141] docs: explain number to devs Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/tests/test_forecasting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 70c07d161d..8a04c13438 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -180,7 +180,7 @@ "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ).floor("1h") - pd.Timedelta(days=30), - "train_period_in_hours": 720, + "train_period_in_hours": 30 * 24, "predict_period_in_hours": 48, "max_forecast_horizon": pd.Timedelta(hours=48), "forecast_frequency": pd.Timedelta(hours=48), @@ -256,7 +256,7 @@ "2025-01-20T12:00:00+01", tz="Europe/Amsterdam", ), - "train-period-in-hours": 720, # from start date to predict start + "train-period-in-hours": 30 * 24, # from start date to predict start "predict-period-in-hours": 120, # from predict start to end date "forecast-frequency": pd.Timedelta( days=5 @@ -482,7 +482,7 @@ ) - pd.Timedelta(days=30), "predict-period-in-hours": 144, # from predict start to end date - "train-period-in-hours": 720, + "train-period-in-hours": 30 * 24, "max-forecast-horizon": pd.Timedelta( days=6 ), # duration between predict start and end date From d13f3d431a2c05ed4cb4b1346112c480960d013a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:54:54 +0100 Subject: [PATCH 083/141] style: black Signed-off-by: F.N. Claessen --- .../data/schemas/tests/test_forecasting.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 8a04c13438..9e4bfc105e 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -133,11 +133,12 @@ ( { "start-predict-date": "2025-01-15T12:00:00+01:00", - "forecast-frequency": "PT12H" + "forecast-frequency": "PT12H", }, { "predict_start": pd.Timestamp( - "2025-01-15T12:00:00.000+01", tz="Europe/Amsterdam"), + "2025-01-15T12:00:00.000+01", tz="Europe/Amsterdam" + ), "start_date": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ).floor("1h") @@ -339,9 +340,7 @@ "max-forecast-horizon": pd.Timedelta( days=5 ), # duration between predict start and end date - "forecast-frequency": pd.Timedelta( - days=5 - ), + "forecast-frequency": pd.Timedelta(days=5), # default values "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), @@ -416,9 +415,7 @@ "max-forecast-horizon": pd.Timedelta( days=3 ), # duration between predict-start and end-date - "forecast-frequency": pd.Timedelta( - days=3 - ), + "forecast-frequency": pd.Timedelta(days=3), # default values "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), @@ -453,7 +450,8 @@ + pd.Timedelta(days=23), "train-period-in-hours": 480, "predict-period-in-hours": 72, - "max-forecast-horizon": pd.Timedelta(days=3), # defaults to prediction period (duration) + # defaults to prediction period (duration) + "max-forecast-horizon": pd.Timedelta(days=3), "forecast-frequency": pd.Timedelta(days=3), # default values "retrain_frequency": 2 * 24, From 3e1503e06de6f1d1b3df30fc73a9ee656ffdedad Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 13:55:29 +0100 Subject: [PATCH 084/141] feat: update test expectations and add another test case Signed-off-by: F.N. Claessen --- .../data/schemas/tests/test_forecasting.py | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 9e4bfc105e..8e4c88846e 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -167,7 +167,7 @@ # - predict-period = FM planning horizon # - max-forecast-horizon = predict-period (actual horizons are 48, 36, 24 and 12) # - forecast-frequency = predict-period (NOT capped by retraining-period, no param changes based on config) - # - 4 cycle, 4 belief times + # - 1 cycle, 1 belief time ( { "retrain-frequency": "PT12H", @@ -193,7 +193,7 @@ "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ), - "n_cycles": 4, + "n_cycles": 1, }, ), # Case 5: predict-period = 10 days and max-forecast-horizon = 12 hours @@ -466,7 +466,7 @@ ( { "end-date": "2025-01-21T12:00:00+01:00", - "retrain-frequency": "P3D", + "retrain-frequency": "P3D", # only comes into play if forecast-frequency is lower than retrain-frequency, which here it is not }, { "end-date": pd.Timestamp( @@ -484,9 +484,49 @@ "max-forecast-horizon": pd.Timedelta( days=6 ), # duration between predict start and end date - "forecast-frequency": pd.Timedelta( - hours=144 + "forecast-frequency": pd.Timedelta(hours=144), + # default values + "max-training-period": pd.Timedelta(days=365), + "retrain-frequency": 3 * 24, + # server now + "save-belief-time": pd.Timestamp( + "2025-01-15T12:23:58.387422+01", + tz="Europe/Amsterdam", ), + "n_cycles": 1, # we expect 1 cycle from the forecast-frequency defaulting to the predict-period + }, + ), + # Case 14: forecast-frequency = 5 days, predict-period = 10 days + # + # User expects to get forecasts for 10 days from two unique viewpoints 5 days apart. + # Specifically, we expect: + # - predict-period = 10 days + # - max-forecast-horizon = predict-period (actual horizons are 10 days and 5 days) + # - forecast-frequency = 5 days + # - retrain-frequency = FM planning horizon + # - 2 cycles, 2 belief times + ( + { + "duration": "P10D", + "forecast-frequency": "P5D", + }, + { + # "end-date": pd.Timestamp( + # "2025-01-21T12:00:00+01", tz="Europe/Amsterdam" + # ), + # "predict-start": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ), + # "start-date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # - pd.Timedelta(days=30), + "predict-period-in-hours": 240, # from predict start to end date + "train-period-in-hours": 30 * 24, + "max-forecast-horizon": pd.Timedelta( + days=10 + ), # duration between predict start and end date + "forecast-frequency": pd.Timedelta(hours=120), # default values "max-training-period": pd.Timedelta(days=365), "retrain-frequency": 2 * 24, From 57d58874fbfe85cd925955df37e5988659f575f6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:04:09 +0100 Subject: [PATCH 085/141] fix: only update default predict-period in case a forecast-frequency was not set explicitly Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 0516113470..d565d9a82b 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -469,7 +469,9 @@ def resolve_config( "max-forecast-horizon must be less than or equal to predict-period", field_name="max_forecast_horizon", ) - elif max_forecast_horizon < predict_period: + elif max_forecast_horizon < predict_period and forecast_frequency is None: + # Update the default predict-period if the user explicitly set a smaller max-forecast-horizon, + # unless they also set a forecast-frequency explicitly predict_period = max_forecast_horizon if forecast_frequency is None: From 187f1b3f194fe7185511a4ef6186d2c92a890eea Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:04:28 +0100 Subject: [PATCH 086/141] delete: obsolete variable Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index d565d9a82b..fb8f995e0c 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -4,7 +4,6 @@ import os from datetime import timedelta -from flask import current_app from isodate.duration import Duration from marshmallow import ( @@ -388,7 +387,6 @@ def resolve_config( target_sensor = data["sensor"] resolution = target_sensor.event_resolution - planning_horizon = current_app.config.get("FLEXMEASURES_PLANNING_HORIZON") now = server_now() floored_now = floor_to_resolution(now, resolution) From acfa1ba0b2a905eca1f4547a0b66a5eb7c6b2b3b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:08:44 +0100 Subject: [PATCH 087/141] fix: we are now guaranteed one cycle, and it is allowed to be smaller than the retrain-frequency Signed-off-by: F.N. Claessen --- .../data/models/forecasting/pipelines/train_predict.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index d6468bae94..33840fdfaf 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -200,12 +200,7 @@ def run( train_end += cycle_frequency predict_start += cycle_frequency predict_end += cycle_frequency - if counter == 0: - logging.info( - f"Train-Predict Pipeline Not Run: start-predict-date + predict-period is {predict_end}, which exceeds end-date {self._parameters['end_date']}. " - f"Try decreasing the predict-period." - ) - elif not as_job: + if not as_job: logging.info( f"Train-Predict Pipeline completed successfully in {cumulative_cycles_runtime:.2f} seconds." ) From b82f0ef3b6eef560ef736d8532f1ad784b4f9ec6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:10:09 +0100 Subject: [PATCH 088/141] fix: counter in train_predict_params starts at 1 Signed-off-by: F.N. Claessen --- flexmeasures/data/models/forecasting/pipelines/train_predict.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 33840fdfaf..3bcc4aa962 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -186,7 +186,7 @@ def run( "train_end": train_end, "predict_start": predict_start, "predict_end": predict_end, - "counter": counter, + "counter": counter + 1, "multiplier": multiplier, } From 8ac9666c5f79ff0215fd2d723c9e87cf8a013247 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:17:28 +0100 Subject: [PATCH 089/141] dev: better error messages Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index 87f8614676..0ec1b9672f 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -44,7 +44,7 @@ def test_trigger_and_fetch_forecasts( trigger_res = client.post( trigger_url, json=payload, headers={"Authorization": token} ) - assert trigger_res.status_code == 200 + assert trigger_res.status_code == 200, trigger_res.json trigger_json = trigger_res.get_json() wrap_up_job = app.queues["forecasting"].fetch_job(trigger_json["forecast"]) @@ -83,7 +83,7 @@ def test_trigger_and_fetch_forecasts( fetch_url = url_for("SensorAPI:get_forecast", id=sensor_0.id, uuid=job_id) res = client.get(fetch_url, headers={"Authorization": token}) - assert res.status_code == 200 + assert res.status_code == 200, res.json data = res.get_json() From d0f0bf65bf2a29dfddfcad19f4a6c4cf40c61630 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:18:10 +0100 Subject: [PATCH 090/141] fix: update test case that tries to get two cycles out of the API Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index 0ec1b9672f..5a1e391aa4 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -37,6 +37,7 @@ def test_trigger_and_fetch_forecasts( "start-predict-date": "2025-01-05T00:00:00+00:00", "end-date": "2025-01-05T02:00:00+00:00", "max-forecast-horizon": "PT1H", + "forecast-frequency": "PT1H", "retrain-frequency": "PT1H", } From 048eb734bfbd931e014775a270f37a0743684b97 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:20:53 +0100 Subject: [PATCH 091/141] fix: expose forecast-frequency to API users Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/sensors.py | 1 - 1 file changed, 1 deletion(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 5145623bfe..8519e4f1ef 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -85,7 +85,6 @@ EXCLUDED_FORECASTING_FIELDS = [ "train_period", "max_training_period", - "forecast_frequency", "sensor_to_save", ] forecasting_trigger_schema_openAPI = make_openapi_compatible(ForecastingTriggerSchema)( From 99b48e7d2d5e032e21605d54474fdaa4adac8ac0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:23:46 +0100 Subject: [PATCH 092/141] docs: update forecast-frequency default description Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index fb8f995e0c..46d6d90796 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -248,7 +248,7 @@ class ForecasterParametersSchema(Schema): required=False, allow_none=True, metadata={ - "description": "How often to recompute forecasts. Defaults to retrain frequency.", + "description": "How often to recompute forecasts. This setting can be used to get forecasts from multiple viewpoints. Defaults to the max-forecast-horizon.", "example": "PT1H", "cli": { "option": "--forecast-frequency", From a2e69ebd3197121088c2f7d7eef2e4573fb37b02 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 10:52:04 +0100 Subject: [PATCH 093/141] docs: add comment Signed-off-by: Mohamed Belhsan Hmida Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 46d6d90796..d05713559c 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -381,7 +381,7 @@ def resolve_config( Choices: 1. If max-forecast-horizon < predict-period, we raise a ValidationError due to incomplete coverage - 2. retraining-frequency becomes the maximum of (FM planning horizon and forecast-frequency) + 2. retraining-frequency becomes the maximum of (FM planning horizon and forecast-frequency, this is capped by the predict-period. """ target_sensor = data["sensor"] From 89f46c9c720bbed279b90c2af15793234a434960 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 11:17:31 +0100 Subject: [PATCH 094/141] docs: add docstring Signed-off-by: Mohamed Belhsan Hmida Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index d05713559c..83459a34cf 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -375,7 +375,7 @@ def resolve_config( """Resolve timing parameters, using sensible defaults and choices. Defaults: - 1. predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) + 1. predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) only if there is one cycle. 2. max-forecast-horizon defaults to the predict-period 3. forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) @@ -471,6 +471,8 @@ def resolve_config( # Update the default predict-period if the user explicitly set a smaller max-forecast-horizon, # unless they also set a forecast-frequency explicitly predict_period = max_forecast_horizon + elif max_forecast_horizon < predict_period and (predict_period // data.get("retrain_frequency")) <= 1: + predict_period = max_forecast_horizon if forecast_frequency is None: forecast_frequency = min( From a1b230f2f5a60fc75a33b08c12fe6eeeaead0556 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:27:47 +0100 Subject: [PATCH 095/141] docs: update comment for selecting a default predict-period Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 83459a34cf..6cd27fd4e1 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -375,7 +375,7 @@ def resolve_config( """Resolve timing parameters, using sensible defaults and choices. Defaults: - 1. predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) only if there is one cycle. + 1. predict-period defaults to minimum of (FM planning horizon and max-forecast-horizon) only if there is a single default viewpoint. 2. max-forecast-horizon defaults to the predict-period 3. forecast-frequency defaults to minimum of (FM planning horizon, predict-period, max-forecast-horizon) From cab3c777c296d6ed0208c6667f4d32952cceccb0 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 14:30:50 +0100 Subject: [PATCH 096/141] fix: remove code after merge conflict Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 6cd27fd4e1..513a07f029 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -471,8 +471,6 @@ def resolve_config( # Update the default predict-period if the user explicitly set a smaller max-forecast-horizon, # unless they also set a forecast-frequency explicitly predict_period = max_forecast_horizon - elif max_forecast_horizon < predict_period and (predict_period // data.get("retrain_frequency")) <= 1: - predict_period = max_forecast_horizon if forecast_frequency is None: forecast_frequency = min( From f7dd44353d8ed1e2e67c8b4a284d41fa34097820 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 15:31:55 +0100 Subject: [PATCH 097/141] style: move flake8 noqa Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 513a07f029..c3ea21b3a3 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -369,9 +369,9 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 ) @post_load(pass_original=True) - def resolve_config( + def resolve_config( # noqa: C901 self, data: dict, original_data: dict | None = None, **kwargs - ) -> dict: # noqa: C901 + ) -> dict: """Resolve timing parameters, using sensible defaults and choices. Defaults: From b682c0e0f03fe304707ad16c1b7d6f5ec09d5600 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 15:56:45 +0100 Subject: [PATCH 098/141] test(docs) update test comments Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/tests/test_forecasting.py | 75 ++++++++++++++----- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 8e4c88846e..693a86ef2c 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -314,10 +314,17 @@ "n_cycles": 1, }, ), - # Case 9: Test when only end date is given with a training period - # We expect the start date to be computed with respect to now. (training period before now (floored)). - # We expect training period of 30 days before predict start and prediction period of 48 hours after predict start, with predict start at server now (floored to hour). - # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period + # Case 9: end-date is given with train-period = 3 days + # + # User expects the start date to be computed from the inferred predict-start and train-period. + # Specifically, we expect: + # - predict-start = server now floored to sensor resolution + # - train-period = 3 days (72 hours) + # - predict-period = 5 days (from predict-start to end-date) + # - max-forecast-horizon = predict-period = 5 days + # - forecast-frequency = predict-period = 5 days + # - retrain-frequency = FM planning horizon + # - 1 cycle, 1 belief time ( { "end-date": "2025-01-20T12:00:00+01:00", @@ -352,10 +359,17 @@ "n_cycles": 1, }, ), - # Case 10: Test when only start date is given with a training period - # We expect the predict start to be computed with respect to the start date (training period after start date). - # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). - # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period + # Case 10: start-date is given with train-period = 3 days + # + # User expects predict-start to be derived from start-date + train-period. + # Specifically, we expect: + # - predict-start = start-date + 3 days + # - predict-period = FM planning horizon (48 hours) + # - end-date = predict-start + 48 hours + # - max-forecast-horizon = predict-period = 48 hours + # - forecast-frequency = predict-period = 48 hours + # - retrain-frequency = FM planning horizon + # - 1 cycle, 1 belief time ( { "start-date": "2024-12-25T00:00:00+01:00", @@ -389,10 +403,18 @@ "n_cycles": 1, }, ), - # Case 11: Test when only start date is given with a duration (prediction period) - # We expect the predict start to be computed with respect to the start date (training period after start date). - # We set training period of 3 days, we expect a prediction period to default 48 hours after predict start, with predict start at server now (floored to hour). - # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period + # Case 11: start-date is given with predict-period duration = 3 days + # + # User expects predict-start to remain based on server now (no train-period given). + # Specifically, we expect: + # - predict-start = server now floored to sensor resolution + # - predict-period = 3 days + # - end-date = predict-start + 3 days + # - train-period derived from start-date to predict-start + # - max-forecast-horizon = predict-period = 3 days + # - forecast-frequency = predict-period = 3 days + # - retrain-frequency = FM planning horizon + # - 1 cycle, 1 belief time ( { "start-date": "2024-12-25T00:00:00+01:00", @@ -427,9 +449,17 @@ "n_cycles": 1, }, ), - # Case 12: Test when only start date is given with both training period 20 days and prediction period 3 days - # We expect the predict start to be computed with respect to the start date (training period after start date). - # 1 cycle expected (1 belief_time for forecast) given the forecast frequency equal defaulted to prediction period + # Case 12: start-date is given with train-period = 20 days and duration = 3 days + # + # User expects both predict-start and end-date to be derived from start-date. + # Specifically, we expect: + # - predict-start = start-date + 20 days + # - predict-period = 3 days + # - end-date = start-date + 23 days + # - max-forecast-horizon = predict-period = 3 days + # - forecast-frequency = predict-period = 3 days + # - retrain-frequency = FM planning horizon + # - 1 cycle, 1 belief time ( { "start-date": "2024-12-01T00:00:00+01:00", @@ -460,9 +490,18 @@ "save-belief-time": None, }, ), - # Case 13: Test when only end date is given with a prediction period: we expect the train start and predict start to both be computed with respect to the end date. - # we expect training period of 30 days before predict_start and prediction period of 3 days after predict_start, with predict_start at server now (floored to hour). - # we expect 2 cycles from the retrain frequency and predict period given the end date + # Case 13: only end-date is given with retrain-frequency = 3 days + # + # User expects train start and predict start to be derived from end-date and defaults. + # Specifically, we expect: + # - predict-start = end-date - default duration (FM planning horizon) + # - train-period = default 30 days + # - start-date = predict-start - 30 days + # - predict-period = 6 days + # - max-forecast-horizon = predict-period = 6 days + # - forecast-frequency = predict-period = 6 days + # - retrain-frequency = 3 days (explicit) + # - 1 cycle, 1 belief time ( { "end-date": "2025-01-21T12:00:00+01:00", From 638517d8745702c7f4a8e62b5b4f1530cf109aef Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 15:43:52 +0100 Subject: [PATCH 099/141] feat: move retrain-frequency to config Signed-off-by: F.N. Claessen --- .../forecasting/pipelines/train_predict.py | 2 +- .../data/schemas/forecasting/pipeline.py | 55 ++++++++----------- .../data/tests/test_train_predict_pipeline.py | 4 +- flexmeasures/ui/static/openapi-specs.json | 16 ++++++ 4 files changed, 42 insertions(+), 35 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 3bcc4aa962..174af39eba 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -159,7 +159,7 @@ def run( ) # How much to move forward to the next cycle one prediction period later cycle_frequency = max( - timedelta(hours=self._parameters["retrain_frequency"]), + self._config["retrain_frequency"], self._parameters["forecast_frequency"], ) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index c3ea21b3a3..be6f9a7167 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -10,6 +10,7 @@ fields, Schema, validates_schema, + validates, pre_load, post_load, ValidationError, @@ -97,6 +98,27 @@ class TrainPredictPipelineConfigSchema(Schema): }, }, ) + retrain_frequency = DurationField( + data_key="retrain-frequency", + load_default=PlanningDurationField.load_default, + allow_none=True, + metadata={ + "description": "Frequency of retraining/prediction cycle (ISO 8601 duration). Defaults to prediction window length if not set.", + "example": "PT24H", + "cli": { + "cli-exclusive": True, + "option": "--retrain-frequency", + }, + }, + ) + + @validates("retrain_frequency") + def validate_parameters(self, value, **kwargs): + if value <= timedelta(0): + raise ValidationError( + "retrain-frequency must be greater than 0", + field_name="retrain_frequency", + ) @post_load def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 @@ -217,19 +239,6 @@ class ForecasterParametersSchema(Schema): }, }, ) - retrain_frequency = DurationField( - data_key="retrain-frequency", - load_default=PlanningDurationField.load_default, - allow_none=True, - metadata={ - "description": "Frequency of retraining/prediction cycle (ISO 8601 duration). Defaults to prediction window length if not set.", - "example": "PT24H", - "cli": { - "cli-exclusive": True, - "option": "--retrain-frequency", - }, - }, - ) max_forecast_horizon = DurationField( data_key="max-forecast-horizon", required=False, @@ -239,7 +248,6 @@ class ForecasterParametersSchema(Schema): "example": "PT48H", "cli": { "option": "--max-forecast-horizon", - "extra_help": "For example, if you have multiple viewpoints (by having set a `retrain-frequency`), then it is equal to the retrain-frequency by default.", }, }, ) @@ -313,7 +321,6 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 end_date = data.get("end_date") predict_start = data.get("start_predict_date", None) train_period = data.get("train_period") - retrain_frequency = data.get("retrain_frequency") max_forecast_horizon = data.get("max_forecast_horizon") forecast_frequency = data.get("forecast_frequency") sensor = data.get("sensor") @@ -342,12 +349,6 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 field_name="train_period", ) - if retrain_frequency is not None and retrain_frequency <= timedelta(0): - raise ValidationError( - "retrain-frequency must be greater than 0", - field_name="retrain_frequency", - ) - if max_forecast_horizon is not None: if max_forecast_horizon % sensor.event_resolution != timedelta(0): raise ValidationError( @@ -478,13 +479,8 @@ def resolve_config( # noqa: C901 predict_period, ) - retrain_frequency = data["retrain_frequency"] - retrain_frequency_in_hours = int(retrain_frequency.total_seconds() / 3600) predict_period_in_hours = int(predict_period.total_seconds() / 3600) - if retrain_frequency_in_hours < 1: - raise ValidationError("retrain-frequency must be at least 1 hour") - if data.get("sensor_to_save") is None: sensor_to_save = target_sensor else: @@ -499,11 +495,7 @@ def resolve_config( # noqa: C901 # Read default from schema model_save_dir = self.fields["model_save_dir"].load_default - n_cycles = max( - predict_period - // max(timedelta(hours=retrain_frequency_in_hours), forecast_frequency), - 1, - ) + n_cycles = max(predict_period // forecast_frequency, 1) return dict( sensor=target_sensor, @@ -515,7 +507,6 @@ def resolve_config( # noqa: C901 max_training_period=max_training_period, predict_start=predict_start, predict_period_in_hours=predict_period_in_hours, - retrain_frequency=retrain_frequency_in_hours, max_forecast_horizon=max_forecast_horizon, forecast_frequency=forecast_frequency, probabilistic=data.get("probabilistic"), diff --git a/flexmeasures/data/tests/test_train_predict_pipeline.py b/flexmeasures/data/tests/test_train_predict_pipeline.py index 8210bbdd59..00b7d0eec4 100644 --- a/flexmeasures/data/tests/test_train_predict_pipeline.py +++ b/flexmeasures/data/tests/test_train_predict_pipeline.py @@ -20,6 +20,7 @@ ( { # "model": "CustomLGBM", + "retrain-frequency": "P0D", # 0 days is expected to fail }, { "sensor": "solar-sensor", @@ -30,7 +31,6 @@ "train-period": "P2D", "sensor-to-save": None, "start-predict-date": "2025-01-02T00:00+02:00", - "retrain-frequency": "P0D", # 0 days is expected to fail "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "probabilistic": False, @@ -104,6 +104,7 @@ { # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], + "retrain-frequency": "P1D", }, { "sensor": "solar-sensor", @@ -114,7 +115,6 @@ "train-period": "P2D", "sensor-to-save": None, "start-predict-date": "2025-01-02T00:00+02:00", - "retrain-frequency": "P1D", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT24H", "probabilistic": False, diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 5071aa8e4a..1d426b15d9 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4153,6 +4153,14 @@ "default": false, "description": "Whether to clip negative values in forecasts. Defaults to None (disabled).", "example": true + }, + "retrain-frequency": { + "type": [ + "string", + "null" + ], + "description": "Frequency of retraining/prediction cycle (ISO 8601 duration). Defaults to prediction window length if not set.", + "example": "PT24H" } }, "additionalProperties": false @@ -4205,6 +4213,14 @@ "description": "Maximum forecast horizon. Defaults to covering the whole prediction period (which itself defaults to 48 hours).", "example": "PT48H" }, + "forecast-frequency": { + "type": [ + "string", + "null" + ], + "description": "How often to recompute forecasts. This setting can be used to get forecasts from multiple viewpoints. Defaults to the max-forecast-horizon.", + "example": "PT1H" + }, "config": { "description": "Changing any of these will result in a new data source ID.", "$ref": "#/components/schemas/TrainPredictPipelineConfig" From 732469e7d51eef48bbb3ecd454a30d170b986fdf Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Fri, 20 Feb 2026 16:03:50 +0100 Subject: [PATCH 100/141] feat(test): uncomment and fix dates params Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/tests/test_forecasting.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 693a86ef2c..f50d1ed4c4 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -550,16 +550,16 @@ "forecast-frequency": "P5D", }, { - # "end-date": pd.Timestamp( - # "2025-01-21T12:00:00+01", tz="Europe/Amsterdam" - # ), - # "predict-start": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ), - # "start-date": pd.Timestamp( - # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - # ) - # - pd.Timedelta(days=30), + "end-date": pd.Timestamp( + "2025-01-25T12:00:00+01", tz="Europe/Amsterdam" + ), + "predict-start": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ), + "start-date": pd.Timestamp( + "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + ) + - pd.Timedelta(days=30), "predict-period-in-hours": 240, # from predict start to end date "train-period-in-hours": 30 * 24, "max-forecast-horizon": pd.Timedelta( From b507d798cd7eaf267db8d49cdc7950cac931add1 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 16:05:02 +0100 Subject: [PATCH 101/141] feat: ensure a retrain-frequency of at least 1 hour Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 ++-- ...train_predict_pipeline.py => test_forecasting_pipeline.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename flexmeasures/data/tests/{test_train_predict_pipeline.py => test_forecasting_pipeline.py} (99%) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index be6f9a7167..8555ba1620 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -114,9 +114,9 @@ class TrainPredictPipelineConfigSchema(Schema): @validates("retrain_frequency") def validate_parameters(self, value, **kwargs): - if value <= timedelta(0): + if value < timedelta(hours=1): raise ValidationError( - "retrain-frequency must be greater than 0", + "retrain-frequency must be at least 1 hour", field_name="retrain_frequency", ) diff --git a/flexmeasures/data/tests/test_train_predict_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py similarity index 99% rename from flexmeasures/data/tests/test_train_predict_pipeline.py rename to flexmeasures/data/tests/test_forecasting_pipeline.py index 00b7d0eec4..4e8fb59191 100644 --- a/flexmeasures/data/tests/test_train_predict_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -36,7 +36,7 @@ "probabilistic": False, }, False, - (ValidationError, "retrain-frequency must be greater than 0"), + (ValidationError, "retrain-frequency must be at least 1 hour"), ), ( { From b12f2df329e282e22bb3b461e8778bb5050be278 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 16:24:32 +0100 Subject: [PATCH 102/141] fix: n_cycles now determined outside of schemas Signed-off-by: F.N. Claessen --- flexmeasures/data/models/forecasting/__init__.py | 2 +- .../models/forecasting/pipelines/train_predict.py | 12 +++++++++++- flexmeasures/data/schemas/forecasting/pipeline.py | 4 ++-- .../data/tests/test_forecasting_pipeline.py | 14 +++++++------- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/flexmeasures/data/models/forecasting/__init__.py b/flexmeasures/data/models/forecasting/__init__.py index 556b2c6004..f6a25d1acd 100644 --- a/flexmeasures/data/models/forecasting/__init__.py +++ b/flexmeasures/data/models/forecasting/__init__.py @@ -141,7 +141,7 @@ def _clean_parameters(self, parameters: dict) -> dict: "output-path", "sensor-to-save", "as-job", - "n_cycles", # Computed internally, still uses snake_case + "m_viewpoints", # Computed internally, still uses snake_case ] for field in fields_to_remove: diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 174af39eba..65ad15fd9f 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -176,9 +176,19 @@ def run( timedelta(hours=1) / sensor_resolution ) # multiplier used to adapt n_steps_to_predict to hours from sensor resolution, e.g. 15 min sensor resolution will have 7*24*4 = 168 predicitons to predict a week + # Compute number of training cycles (at least 1) + n_cycles = max( + timedelta(hours=self._parameters["predict_period_in_hours"]) + // max( + self._config["retrain_frequency"], + self._parameters["forecast_frequency"], + ), + 1, + ) + cumulative_cycles_runtime = 0 # To track the cumulative runtime of TrainPredictPipeline cycles when not running as a job. cycles_job_params = [] - for counter in range(self._parameters["n_cycles"]): + for counter in range(n_cycles): predict_end = min(predict_end, self._parameters["end_date"]) train_predict_params = { diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 8555ba1620..43c3c4dedb 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -495,7 +495,7 @@ def resolve_config( # noqa: C901 # Read default from schema model_save_dir = self.fields["model_save_dir"].load_default - n_cycles = max(predict_period // forecast_frequency, 1) + m_viewpoints = max(predict_period // forecast_frequency, 1) return dict( sensor=target_sensor, @@ -512,7 +512,7 @@ def resolve_config( # noqa: C901 probabilistic=data.get("probabilistic"), sensor_to_save=sensor_to_save, save_belief_time=save_belief_time, - n_cycles=n_cycles, + m_viewpoints=m_viewpoints, ) diff --git a/flexmeasures/data/tests/test_forecasting_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py index 4e8fb59191..49b8e557d7 100644 --- a/flexmeasures/data/tests/test_forecasting_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -52,7 +52,7 @@ "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", - "forecast-frequency": "PT24H", + "forecast-frequency": "PT24H", # 1 cycle and 1 viewpoint "probabilistic": False, }, True, @@ -73,7 +73,7 @@ "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", - "forecast-frequency": "PT24H", + "forecast-frequency": "PT24H", # 1 cycle and 1 viewpoint "probabilistic": False, }, False, @@ -178,18 +178,18 @@ def test_train_predict_pipeline( # noqa: C901 forecasts = sensor.search_beliefs(source_types=["forecaster"]) dg_params = pipeline._parameters # parameters stored in the data generator - n_cycles = (dg_params["end_date"] - dg_params["predict_start"]) / ( + m_viewpoints = (dg_params["end_date"] - dg_params["predict_start"]) / ( dg_params["forecast_frequency"] ) # 1 hour of forecasts is saved over 4 15-minute resolution events n_events_per_horizon = timedelta(hours=1) / dg_params["sensor"].event_resolution n_hourly_horizons = dg_params["max_forecast_horizon"] // timedelta(hours=1) assert ( - len(forecasts) == n_cycles * n_hourly_horizons * n_events_per_horizon - ), f"we expect 4 forecasts per horizon for each cycle within the prediction window, and {n_cycles} cycles with each {n_hourly_horizons} hourly horizons" + len(forecasts) == m_viewpoints * n_hourly_horizons * n_events_per_horizon + ), f"we expect 4 forecasts per horizon for each viewpoint within the prediction window, and {m_viewpoints} viewpoints with each {n_hourly_horizons} hourly horizons" assert ( - forecasts.lineage.number_of_belief_times == n_cycles - ), f"we expect 1 belief time per cycle, and {n_cycles} cycles" + forecasts.lineage.number_of_belief_times == m_viewpoints + ), f"we expect {m_viewpoints} viewpoints" source = forecasts.lineage.sources[0] assert "TrainPredictPipeline" in str( source From ae24461b0fb8748cc669df66e416c591954dddac Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 16:32:29 +0100 Subject: [PATCH 103/141] fix: update test coverage of ForecasterParametersSchema Signed-off-by: F.N. Claessen --- .../data/schemas/tests/test_forecasting.py | 164 +++++++++--------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 693a86ef2c..a6eb0c5c95 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -40,7 +40,7 @@ "predict-period-in-hours": 48, "max-forecast-horizon": pd.Timedelta(days=2), "train-period-in-hours": 24 * 30, - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), "forecast-frequency": pd.Timedelta(days=2), # the one belief time corresponds to server now @@ -48,7 +48,7 @@ "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", ), - "n_cycles": 1, + "m_viewpoints": 1, }, ), # Case 1: predict-period = 12 hours @@ -78,12 +78,12 @@ "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ) + pd.Timedelta(hours=12), - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ), - "n_cycles": 1, + "m_viewpoints": 1, }, ), # Case 2: max-forecast-horizon = 12 hours # here we have issue that predict period is defaulted to 48 hours, but max-forecast-horizon is set to 12 hours, which should be less than or equal to predict-period @@ -113,12 +113,12 @@ "predict_period_in_hours": 12, "max_forecast_horizon": pd.Timedelta(hours=12), "forecast_frequency": pd.Timedelta(hours=12), - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ), - "n_cycles": 1, + "m_viewpoints": 1, }, ), # Case 3: forecast-frequency = 12 hours @@ -152,12 +152,12 @@ ) + pd.Timedelta(hours=48), "max_training_period": pd.Timedelta(days=365), - "retrain-frequency": 2 * 24, + # "retrain-frequency": 2 * 24, # this is the first belief time of the four belief times "save_belief_time": pd.Timestamp( "2025-01-15T12:00:00.00+01", tz="Europe/Amsterdam" ), - "n_cycles": 1, + "m_viewpoints": 4, }, ), # Case 4: (config) retraining-period = 12 hours @@ -168,34 +168,34 @@ # - max-forecast-horizon = predict-period (actual horizons are 48, 36, 24 and 12) # - forecast-frequency = predict-period (NOT capped by retraining-period, no param changes based on config) # - 1 cycle, 1 belief time - ( - { - "retrain-frequency": "PT12H", - "end-date": "2025-01-17T12:00:00+01:00", - }, - { - "predict_start": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h"), - "start_date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), - "train_period_in_hours": 30 * 24, - "predict_period_in_hours": 48, - "max_forecast_horizon": pd.Timedelta(hours=48), - "forecast_frequency": pd.Timedelta(hours=48), - "end_date": pd.Timestamp( - "2025-01-17T12:00:00+01", tz="Europe/Amsterdam" - ), - "retrain-frequency": 12, - "max_training_period": pd.Timedelta(days=365), - "save_belief_time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ), - "n_cycles": 1, - }, - ), + # ( + # { + # "retrain-frequency": "PT12H", + # "end-date": "2025-01-17T12:00:00+01:00", + # }, + # { + # "predict_start": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h"), + # "start_date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h") + # - pd.Timedelta(days=30), + # "train_period_in_hours": 30 * 24, + # "predict_period_in_hours": 48, + # "max_forecast_horizon": pd.Timedelta(hours=48), + # "forecast_frequency": pd.Timedelta(hours=48), + # "end_date": pd.Timestamp( + # "2025-01-17T12:00:00+01", tz="Europe/Amsterdam" + # ), + # "retrain-frequency": 12, + # "max_training_period": pd.Timedelta(days=365), + # "save_belief_time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ), + # "m_viewpoints": 1, + # }, + # ), # Case 5: predict-period = 10 days and max-forecast-horizon = 12 hours # # User expects to get a ValidationError for having set parameters that won't give complete coverage of the predict-period. @@ -266,14 +266,14 @@ days=5 ), # duration between predict start and end date # default values - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", ), - "n_cycles": 1, + "m_viewpoints": 1, }, ), # Case 8: end-date = almost 4.5 days after now, start-date is 26.5 days before now @@ -304,14 +304,14 @@ "train-period-in-hours": 636, # hours between start date and predict start "max-forecast-horizon": pd.Timedelta(hours=108), "forecast-frequency": pd.Timedelta(hours=108), - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", ), - "n_cycles": 1, + "m_viewpoints": 1, }, ), # Case 9: end-date is given with train-period = 3 days @@ -349,14 +349,14 @@ ), # duration between predict start and end date "forecast-frequency": pd.Timedelta(days=5), # default values - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", ), - "n_cycles": 1, + "m_viewpoints": 1, }, ), # Case 10: start-date is given with train-period = 3 days @@ -396,11 +396,11 @@ ), # duration between predict start and end date # default values "predict-period-in-hours": 48, - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency "save-belief-time": None, - "n_cycles": 1, + "m_viewpoints": 1, }, ), # Case 11: start-date is given with predict-period duration = 3 days @@ -439,14 +439,14 @@ ), # duration between predict-start and end-date "forecast-frequency": pd.Timedelta(days=3), # default values - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", ), - "n_cycles": 1, + "m_viewpoints": 1, }, ), # Case 12: start-date is given with train-period = 20 days and duration = 3 days @@ -484,7 +484,7 @@ "max-forecast-horizon": pd.Timedelta(days=3), "forecast-frequency": pd.Timedelta(days=3), # default values - "retrain_frequency": 2 * 24, + # "retrain_frequency": 2 * 24, "max-training-period": pd.Timedelta(days=365), # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency "save-belief-time": None, @@ -502,39 +502,39 @@ # - forecast-frequency = predict-period = 6 days # - retrain-frequency = 3 days (explicit) # - 1 cycle, 1 belief time - ( - { - "end-date": "2025-01-21T12:00:00+01:00", - "retrain-frequency": "P3D", # only comes into play if forecast-frequency is lower than retrain-frequency, which here it is not - }, - { - "end-date": pd.Timestamp( - "2025-01-21T12:00:00+01", tz="Europe/Amsterdam" - ), - "predict-start": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ), - "start-date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - - pd.Timedelta(days=30), - "predict-period-in-hours": 144, # from predict start to end date - "train-period-in-hours": 30 * 24, - "max-forecast-horizon": pd.Timedelta( - days=6 - ), # duration between predict start and end date - "forecast-frequency": pd.Timedelta(hours=144), - # default values - "max-training-period": pd.Timedelta(days=365), - "retrain-frequency": 3 * 24, - # server now - "save-belief-time": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ), - "n_cycles": 1, # we expect 1 cycle from the forecast-frequency defaulting to the predict-period - }, - ), + # ( + # { + # "end-date": "2025-01-21T12:00:00+01:00", + # "retrain-frequency": "P3D", # only comes into play if forecast-frequency is lower than retrain-frequency, which here it is not + # }, + # { + # "end-date": pd.Timestamp( + # "2025-01-21T12:00:00+01", tz="Europe/Amsterdam" + # ), + # "predict-start": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ), + # "start-date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # - pd.Timedelta(days=30), + # "predict-period-in-hours": 144, # from predict start to end date + # "train-period-in-hours": 30 * 24, + # "max-forecast-horizon": pd.Timedelta( + # days=6 + # ), # duration between predict start and end date + # "forecast-frequency": pd.Timedelta(hours=144), + # # default values + # "max-training-period": pd.Timedelta(days=365), + # "retrain-frequency": 3 * 24, + # # server now + # "save-belief-time": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ), + # "m_viewpoints": 1, # we expect 1 cycle from the forecast-frequency defaulting to the predict-period + # }, + # ), # Case 14: forecast-frequency = 5 days, predict-period = 10 days # # User expects to get forecasts for 10 days from two unique viewpoints 5 days apart. @@ -568,13 +568,13 @@ "forecast-frequency": pd.Timedelta(hours=120), # default values "max-training-period": pd.Timedelta(days=365), - "retrain-frequency": 2 * 24, + # "retrain-frequency": 2 * 24, # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", ), - "n_cycles": 2, # we expect 2 cycles from the retrain frequency and predict period given the end date + "m_viewpoints": 2, # we expect 2 cycles from the retrain frequency and predict period given the end date }, ), ], From 61329d1b5bdb4770e73ffa3d9e111bef54ea1344 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 16:36:22 +0100 Subject: [PATCH 104/141] dev: partial fix for failing test Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index 5a1e391aa4..a74c972adf 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -38,7 +38,9 @@ def test_trigger_and_fetch_forecasts( "end-date": "2025-01-05T02:00:00+00:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", - "retrain-frequency": "PT1H", + "config": { + "retrain-frequency": "PT1H", + }, } trigger_url = url_for("SensorAPI:trigger_forecast", id=sensor_0.id) From 027913a54d764c661b7219c8cd0c06e0e18bdf49 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 16:38:36 +0100 Subject: [PATCH 105/141] fix: test_missing_data_logs_warning Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/test_forecasting_pipeline.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/flexmeasures/data/tests/test_forecasting_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py index 49b8e557d7..0efbc560a0 100644 --- a/flexmeasures/data/tests/test_forecasting_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -287,7 +287,6 @@ def test_train_predict_pipeline( # noqa: C901 "end-date": "2025-01-30T00:00+02:00", "sensor-to-save": None, "start-predict-date": "2025-01-25T00:00+02:00", - "retrain-frequency": "P1D", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "probabilistic": False, @@ -308,7 +307,6 @@ def test_train_predict_pipeline( # noqa: C901 "end-date": "2025-01-30T00:00+02:00", "sensor-to-save": None, "start-predict-date": "2025-01-25T00:00+02:00", - "retrain-frequency": "P1D", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "probabilistic": False, From f7e8c759bb11015df5fd2274aa7745b535cc97be Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 16:44:54 +0100 Subject: [PATCH 106/141] fix: test_train_period_capped_logs_warning Signed-off-by: F.N. Claessen --- flexmeasures/data/tests/test_forecasting_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/tests/test_forecasting_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py index 0efbc560a0..3ee635be5a 100644 --- a/flexmeasures/data/tests/test_forecasting_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -362,6 +362,7 @@ def test_missing_data_logs_warning( ( { # "model": "CustomLGBM", + "retrain-frequency": "P1D", }, { "sensor": "solar-sensor", @@ -372,7 +373,6 @@ def test_missing_data_logs_warning( "max-training-period": "P10D", # cap at 10 days "sensor-to-save": None, "start-predict-date": "2025-01-25T00:00+02:00", - "retrain-frequency": "P1D", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "probabilistic": False, From a689ad4a612c06580fc9556cc0db4e9523dd66a3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 16:58:06 +0100 Subject: [PATCH 107/141] fix: test_trigger_and_fetch_forecasts Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/sensors.py | 2 +- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 8519e4f1ef..0dfb968faf 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1620,7 +1620,7 @@ def trigger_forecast(self, id: int, **params): forecaster = get_data_generator( source=None, model=model, - config={}, + config=parameters.pop("config", {}), save_config=True, data_generator_type=Forecaster, ) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index a74c972adf..fd883ce787 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -78,7 +78,7 @@ def test_trigger_and_fetch_forecasts( payload["sensor"] = sensor_1.id # Run pipeline manually to compute expected forecasts - pipeline = TrainPredictPipeline() + pipeline = TrainPredictPipeline(config=payload.pop("config", {})) pipeline.compute(parameters=payload) # Fetch forecasts for each job From ff98cc38e37d0d6c62cc553b34f900fc65c0807b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 20 Feb 2026 17:14:30 +0100 Subject: [PATCH 108/141] fix: remove sensor from documented payload (it's in the URI path already) Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/sensors.py | 2 +- flexmeasures/ui/static/openapi-specs.json | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 0dfb968faf..507ba778cb 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -89,7 +89,7 @@ ] forecasting_trigger_schema_openAPI = make_openapi_compatible(ForecastingTriggerSchema)( partial=True, - exclude=EXCLUDED_FORECASTING_FIELDS, + exclude=EXCLUDED_FORECASTING_FIELDS + ["sensor"], ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 1d426b15d9..d7d4377d30 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4168,11 +4168,6 @@ "forecasting_trigger_schema_openAPI": { "type": "object", "properties": { - "sensor": { - "type": "integer", - "description": "ID of the sensor to forecast.", - "example": 2092 - }, "start-date": { "type": [ "string", From ea67caa4c405aa7b989b3059372e706427e797f7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 21 Feb 2026 09:43:24 +0100 Subject: [PATCH 109/141] feat: move training timing fields from parameters to config Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/sensors.py | 12 +- .../api/v3_0/tests/test_forecasting_api.py | 2 +- .../data/models/forecasting/__init__.py | 1 + .../forecasting/pipelines/train_predict.py | 26 +- .../data/schemas/forecasting/pipeline.py | 180 ++++++------- .../data/schemas/tests/test_forecasting.py | 242 +++++++++--------- .../data/tests/test_forecasting_pipeline.py | 30 +-- 7 files changed, 245 insertions(+), 248 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 507ba778cb..0b66bc31ce 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -83,13 +83,15 @@ # Create ForecasterParametersSchema OpenAPI compatible schema EXCLUDED_FORECASTING_FIELDS = [ - "train_period", - "max_training_period", + # todo: hide these in the config schema instead + # "train_period", + # "max_training_period", "sensor_to_save", ] forecasting_trigger_schema_openAPI = make_openapi_compatible(ForecastingTriggerSchema)( - partial=True, - exclude=EXCLUDED_FORECASTING_FIELDS + ["sensor"], + # partial=True, + exclude=EXCLUDED_FORECASTING_FIELDS + + ["sensor"], ) @@ -1533,7 +1535,7 @@ def get_status(self, id, sensor): @route("//forecasts/trigger", methods=["POST"]) @use_args( ForecastingTriggerSchema( - partial=True, + # partial=True, exclude=EXCLUDED_FORECASTING_FIELDS, ), location="combined_sensor_data_description", diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index fd883ce787..3f51aad8a3 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -33,12 +33,12 @@ def test_trigger_and_fetch_forecasts( # Trigger job payload = { - "start-date": "2025-01-01T00:00:00+00:00", "start-predict-date": "2025-01-05T00:00:00+00:00", "end-date": "2025-01-05T02:00:00+00:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "config": { + "start-date": "2025-01-01T00:00:00+00:00", "retrain-frequency": "PT1H", }, } diff --git a/flexmeasures/data/models/forecasting/__init__.py b/flexmeasures/data/models/forecasting/__init__.py index f6a25d1acd..abcef2a02c 100644 --- a/flexmeasures/data/models/forecasting/__init__.py +++ b/flexmeasures/data/models/forecasting/__init__.py @@ -142,6 +142,7 @@ def _clean_parameters(self, parameters: dict) -> dict: "sensor-to-save", "as-job", "m_viewpoints", # Computed internally, still uses snake_case + "sensor", # todo: forecaster parameters should not be saved by default altogether ] for field in fields_to_remove: diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 65ad15fd9f..ac8b49c7df 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -74,7 +74,9 @@ def run_cycle( past_regressors=self._config["past_regressors"], target_sensor=self._parameters["sensor"], model_save_dir=self._parameters["model_save_dir"], - n_steps_to_predict=self._parameters["train_period_in_hours"] * multiplier, + n_steps_to_predict=(predict_start - train_start) + // timedelta(hours=1) + * multiplier, max_forecast_horizon=self._parameters["max_forecast_horizon"] // self._parameters["sensor"].event_resolution, event_starts_after=train_start, @@ -166,10 +168,28 @@ def run( predict_start = self._parameters["predict_start"] predict_end = predict_start + cycle_frequency - train_start = predict_start - timedelta( - hours=self._parameters["train_period_in_hours"] + # Determine training window (start, end) + train_start = self._config.get("start_date", None) + training_period_in_hours = self._config.get("train_period_in_hours", None) + training_period = ( + timedelta(hours=training_period_in_hours) + if training_period_in_hours is not None + else None ) + if training_period is not None: + train_start_from_training_period = predict_start - training_period + if train_start is None: + train_start = train_start_from_training_period + else: + train_start = max(train_start, train_start_from_training_period) + train_start_from_max_training_period = ( + predict_start - self._config["max_training_period"] + ) + train_start = max(train_start, train_start_from_max_training_period) train_end = predict_start + min_training_period = timedelta(days=2) + if train_end - train_start < min_training_period: + train_start = train_end - min_training_period sensor_resolution = self._parameters["sensor"].event_resolution multiplier = int( diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 43c3c4dedb..df3b4fcdd7 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -98,6 +98,43 @@ class TrainPredictPipelineConfigSchema(Schema): }, }, ) + start_date = AwareDateTimeOrDateField( + data_key="start-date", + required=False, + allow_none=True, + metadata={ + "description": "Timestamp marking the start of training data. Defaults to train_period before start_predict_date if not set.", + "example": "2025-01-01T00:00:00+01:00", + "cli": { + "option": "--start-date", + "aliases": ["--train-start"], + }, + }, + ) + train_period = DurationField( + data_key="train-period", + load_default=timedelta(days=30), + allow_none=True, + metadata={ + "description": "Duration of the initial training period (ISO 8601 format, min 2 days). If not set, derived from start_date and start_predict_date or defaults to P30D (30 days).", + "example": "P7D", + "cli": { + "option": "--train-period", + }, + }, + ) + max_training_period = DurationField( + data_key="max-training-period", + load_default=timedelta(days=365), + allow_none=True, + metadata={ + "description": "Maximum duration of the training period. Defaults to 1 year (P1Y).", + "example": "P1Y", + "cli": { + "option": "--max-training-period", + }, + }, + ) retrain_frequency = DurationField( data_key="retrain-frequency", load_default=PlanningDurationField.load_default, @@ -112,14 +149,31 @@ class TrainPredictPipelineConfigSchema(Schema): }, ) - @validates("retrain_frequency") - def validate_parameters(self, value, **kwargs): - if value < timedelta(hours=1): + @validates_schema + def validate_parameters(self, data: dict, **kwargs): # noqa: C901 + if data["retrain_frequency"] < timedelta(hours=1): raise ValidationError( "retrain-frequency must be at least 1 hour", field_name="retrain_frequency", ) + train_period = data.get("train_period") + max_training_period = data.get("max_training_period") + + if train_period is not None and train_period < timedelta(days=2): + raise ValidationError( + "train-period must be at least 2 days (48 hours)", + field_name="train_period", + ) + + if isinstance(max_training_period, Duration): + # DurationField only returns Duration when years/months are present + raise ValidationError( + "max-training-period must be specified using days or smaller units " + "(e.g. P365D, PT48H). Years and months are not supported.", + field_name="max_training_period", + ) + @post_load def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 @@ -135,6 +189,16 @@ def resolve_config(self, data: dict, **kwargs) -> dict: # noqa: C901 data["future_regressors"] = future_regressors data["past_regressors"] = past_regressors + + train_period_in_hours = data["train_period"] // timedelta(hours=1) + max_training_period = data["max_training_period"] + if train_period_in_hours > max_training_period // timedelta(hours=1): + train_period_in_hours = max_training_period // timedelta(hours=1) + logging.warning( + f"train-period is greater than max-training-period ({max_training_period}), setting train-period to max-training-period", + ) + + data["train_period_in_hours"] = train_period_in_hours return data @@ -180,19 +244,6 @@ class ForecasterParametersSchema(Schema): }, }, ) - start_date = AwareDateTimeOrDateField( - data_key="start-date", - required=False, - allow_none=True, - metadata={ - "description": "Timestamp marking the start of training data. Defaults to train_period before start_predict_date if not set.", - "example": "2025-01-01T00:00:00+01:00", - "cli": { - "option": "--start-date", - "aliases": ["--train-start"], - }, - }, - ) duration = PlanningDurationField( load_default=PlanningDurationField.load_default, metadata=dict( @@ -214,18 +265,6 @@ class ForecasterParametersSchema(Schema): }, }, ) - train_period = DurationField( - data_key="train-period", - required=False, - allow_none=True, - metadata={ - "description": "Duration of the initial training period (ISO 8601 format, min 2 days). If not set, derived from start_date and start_predict_date or defaults to P30D (30 days).", - "example": "P7D", - "cli": { - "option": "--train-period", - }, - }, - ) start_predict_date = AwareDateTimeOrDateField( data_key="start-predict-date", required=False, @@ -287,18 +326,6 @@ class ForecasterParametersSchema(Schema): }, }, ) - max_training_period = DurationField( - data_key="max-training-period", - required=False, - allow_none=True, - metadata={ - "description": "Maximum duration of the training period. Defaults to 1 year (P1Y).", - "example": "P1Y", - "cli": { - "option": "--max-training-period", - }, - }, - ) @pre_load def sanitize_input(self, data, **kwargs): @@ -317,38 +344,30 @@ def sanitize_input(self, data, **kwargs): @validates_schema def validate_parameters(self, data: dict, **kwargs): # noqa: C901 - start_date = data.get("start_date") end_date = data.get("end_date") predict_start = data.get("start_predict_date", None) - train_period = data.get("train_period") max_forecast_horizon = data.get("max_forecast_horizon") forecast_frequency = data.get("forecast_frequency") sensor = data.get("sensor") - max_training_period = data.get("max_training_period") - if start_date is not None and end_date is not None and start_date >= end_date: - raise ValidationError( - "start-date must be before end-date", field_name="start_date" - ) + # todo: consider moving this to the run method in train_predict.py + # if start_date is not None and end_date is not None and start_date >= end_date: + # raise ValidationError( + # "start-date must be before end-date", field_name="start_date" + # ) if predict_start: - if start_date is not None and predict_start < start_date: - raise ValidationError( - "start-predict-date cannot be before start-date", - field_name="start_predict_date", - ) + # if start_date is not None and predict_start < start_date: + # raise ValidationError( + # "start-predict-date cannot be before start-date", + # field_name="start_predict_date", + # ) if end_date is not None and predict_start >= end_date: raise ValidationError( "start-predict-date must be before end-date", field_name="start_predict_date", ) - if train_period is not None and train_period < timedelta(days=2): - raise ValidationError( - "train-period must be at least 2 days (48 hours)", - field_name="train_period", - ) - if max_forecast_horizon is not None: if max_forecast_horizon % sensor.event_resolution != timedelta(0): raise ValidationError( @@ -361,14 +380,6 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 f"forecast-frequency must be a multiple of the sensor resolution ({sensor.event_resolution})" ) - if isinstance(max_training_period, Duration): - # DurationField only returns Duration when years/months are present - raise ValidationError( - "max-training-period must be specified using days or smaller units " - "(e.g. P365D, PT48H). Years and months are not supported.", - field_name="max_training_period", - ) - @post_load(pass_original=True) def resolve_config( # noqa: C901 self, data: dict, original_data: dict | None = None, **kwargs @@ -404,43 +415,9 @@ def resolve_config( # noqa: C901 now if data.get("start_predict_date") is None else predict_start ) - if ( - data.get("start_predict_date") is None - and data.get("train_period") - and data.get("start_date") - ): - predict_start = data["start_date"] + data["train_period"] - save_belief_time = None - - if data.get("train_period") is None and data.get("start_date") is None: - train_period_in_hours = 30 * 24 # Set default train_period value to 30 days - - elif data.get("train_period") is None and data.get("start_date"): - train_period_in_hours = int( - (predict_start - data["start_date"]).total_seconds() / 3600 - ) - else: - train_period_in_hours = data["train_period"] // timedelta(hours=1) - - if train_period_in_hours < 48: - raise ValidationError( - "train-period must be at least 2 days (48 hours)", - field_name="train_period", - ) - max_training_period = data.get("max_training_period") or timedelta(days=365) - if train_period_in_hours > max_training_period // timedelta(hours=1): - train_period_in_hours = max_training_period // timedelta(hours=1) - logging.warning( - f"train-period is greater than max-training-period ({max_training_period}), setting train-period to max-training-period", - ) if data.get("end_date") is None: data["end_date"] = predict_start + data["duration"] - if data.get("start_date") is None: - start_date = predict_start - timedelta(hours=train_period_in_hours) - else: - start_date = data["start_date"] - predict_period = ( data["end_date"] - predict_start if data.get("end_date") @@ -501,10 +478,7 @@ def resolve_config( # noqa: C901 sensor=target_sensor, model_save_dir=model_save_dir, output_path=output_path, - start_date=start_date, end_date=data["end_date"], - train_period_in_hours=train_period_in_hours, - max_training_period=max_training_period, predict_start=predict_start, predict_period_in_hours=predict_period_in_hours, max_forecast_horizon=max_forecast_horizon, diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 79a141276a..5483e7c75e 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -27,10 +27,10 @@ "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ).floor("1h"), # default training period 30 days before predict start - "start-date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), + # "start-date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h") + # - pd.Timedelta(days=30), # default prediction period 48 hours after predict start "end-date": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" @@ -39,9 +39,9 @@ # these are set by the schema defaults "predict-period-in-hours": 48, "max-forecast-horizon": pd.Timedelta(days=2), - "train-period-in-hours": 24 * 30, + # "train-period-in-hours": 24 * 30, # "retrain_frequency": 2 * 24, - "max-training-period": pd.Timedelta(days=365), + # "max-training-period": pd.Timedelta(days=365), "forecast-frequency": pd.Timedelta(days=2), # the one belief time corresponds to server now "save-belief-time": pd.Timestamp( @@ -66,11 +66,11 @@ "predict_start": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ).floor("1h"), - "start_date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), - "train_period_in_hours": 24 * 30, + # "start_date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h") + # - pd.Timedelta(days=30), + # "train_period_in_hours": 24 * 30, "predict_period_in_hours": 12, "max_forecast_horizon": pd.Timedelta(hours=12), "forecast_frequency": pd.Timedelta(hours=12), @@ -79,7 +79,7 @@ ) + pd.Timedelta(hours=12), # "retrain_frequency": 2 * 24, - "max_training_period": pd.Timedelta(days=365), + # "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ), @@ -101,20 +101,20 @@ "predict_start": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ).floor("1h"), - "start_date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), + # "start_date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h") + # - pd.Timedelta(days=30), "end_date": pd.Timestamp( "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ) + pd.Timedelta(hours=48), - "train_period_in_hours": 30 * 24, + # "train_period_in_hours": 30 * 24, "predict_period_in_hours": 12, "max_forecast_horizon": pd.Timedelta(hours=12), "forecast_frequency": pd.Timedelta(hours=12), # "retrain_frequency": 2 * 24, - "max_training_period": pd.Timedelta(days=365), + # "max_training_period": pd.Timedelta(days=365), "save_belief_time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" ), @@ -139,11 +139,11 @@ "predict_start": pd.Timestamp( "2025-01-15T12:00:00.000+01", tz="Europe/Amsterdam" ), - "start_date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" - ).floor("1h") - - pd.Timedelta(days=30), - "train_period_in_hours": 30 * 24, + # "start_date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam" + # ).floor("1h") + # - pd.Timedelta(days=30), + # "train_period_in_hours": 30 * 24, "predict_period_in_hours": 48, "max_forecast_horizon": pd.Timedelta(hours=48), "forecast_frequency": pd.Timedelta(hours=12), @@ -151,7 +151,7 @@ "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ) + pd.Timedelta(hours=48), - "max_training_period": pd.Timedelta(days=365), + # "max_training_period": pd.Timedelta(days=365), # "retrain-frequency": 2 * 24, # this is the first belief time of the four belief times "save_belief_time": pd.Timestamp( @@ -246,18 +246,18 @@ "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", ).floor("1h"), - "start-date": pd.Timestamp( - "2025-01-15T12:23:58.387422+01", - tz="Europe/Amsterdam", - ).floor("1h") - - pd.Timedelta( - days=30 - ), # default training period 30 days before predict start + # "start-date": pd.Timestamp( + # "2025-01-15T12:23:58.387422+01", + # tz="Europe/Amsterdam", + # ).floor("1h") + # - pd.Timedelta( + # days=30 + # ), # default training period 30 days before predict start "end-date": pd.Timestamp( "2025-01-20T12:00:00+01", tz="Europe/Amsterdam", ), - "train-period-in-hours": 30 * 24, # from start date to predict start + # "train-period-in-hours": 30 * 24, # from start date to predict start "predict-period-in-hours": 120, # from predict start to end date "forecast-frequency": pd.Timedelta( days=5 @@ -267,7 +267,7 @@ ), # duration between predict start and end date # default values # "retrain_frequency": 2 * 24, - "max-training-period": pd.Timedelta(days=365), + # "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", @@ -286,13 +286,13 @@ # - training-period = 636 hours ( { - "start-date": "2024-12-20T00:00:00+01:00", + # "start-date": "2024-12-20T00:00:00+01:00", "end-date": "2025-01-20T00:00:00+01:00", }, { - "start-date": pd.Timestamp( - "2024-12-20T00:00:00+01", tz="Europe/Amsterdam" - ), + # "start-date": pd.Timestamp( + # "2024-12-20T00:00:00+01", tz="Europe/Amsterdam" + # ), "end-date": pd.Timestamp( "2025-01-20T00:00:00+01", tz="Europe/Amsterdam" ), @@ -301,11 +301,11 @@ tz="Europe/Amsterdam", ).floor("1h"), "predict-period-in-hours": 108, # hours from predict start to end date - "train-period-in-hours": 636, # hours between start date and predict start + # "train-period-in-hours": 636, # hours between start date and predict start "max-forecast-horizon": pd.Timedelta(hours=108), "forecast-frequency": pd.Timedelta(hours=108), # "retrain_frequency": 2 * 24, - "max-training-period": pd.Timedelta(days=365), + # "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", @@ -328,7 +328,7 @@ ( { "end-date": "2025-01-20T12:00:00+01:00", - "train-period": "P3D", + # "train-period": "P3D", }, { "end-date": pd.Timestamp( @@ -338,11 +338,11 @@ "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", ).floor("1h"), - "start-date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - - pd.Timedelta(days=3), - "train-period-in-hours": 72, # from start date to predict start + # "start-date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # - pd.Timedelta(days=3), + # "train-period-in-hours": 72, # from start date to predict start "predict-period-in-hours": 120, # from predict start to end date "max-forecast-horizon": pd.Timedelta( days=5 @@ -350,7 +350,7 @@ "forecast-frequency": pd.Timedelta(days=5), # default values # "retrain_frequency": 2 * 24, - "max-training-period": pd.Timedelta(days=365), + # "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", @@ -370,39 +370,39 @@ # - forecast-frequency = predict-period = 48 hours # - retrain-frequency = FM planning horizon # - 1 cycle, 1 belief time - ( - { - "start-date": "2024-12-25T00:00:00+01:00", - "train-period": "P3D", - }, - { - "start-date": pd.Timestamp( - "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" - ), - "predict-start": pd.Timestamp( - "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=3), - "end-date": pd.Timestamp( - "2024-12-28T00:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=2), - "train-period-in-hours": 72, - "max-forecast-horizon": pd.Timedelta( - days=2 - ), # duration between predict start and end date - "forecast-frequency": pd.Timedelta( - days=2 - ), # duration between predict start and end date - # default values - "predict-period-in-hours": 48, - # "retrain_frequency": 2 * 24, - "max-training-period": pd.Timedelta(days=365), - # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency - "save-belief-time": None, - "m_viewpoints": 1, - }, - ), + # ( + # { + # # "start-date": "2024-12-25T00:00:00+01:00", + # # "train-period": "P3D", + # }, + # { + # # "start-date": pd.Timestamp( + # # "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" + # # ), + # "predict-start": pd.Timestamp( + # "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(days=3), + # "end-date": pd.Timestamp( + # "2024-12-28T00:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(days=2), + # # "train-period-in-hours": 72, + # "max-forecast-horizon": pd.Timedelta( + # days=2 + # ), # duration between predict start and end date + # "forecast-frequency": pd.Timedelta( + # days=2 + # ), # duration between predict start and end date + # # default values + # "predict-period-in-hours": 48, + # # "retrain_frequency": 2 * 24, + # # "max-training-period": pd.Timedelta(days=365), + # # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency + # "save-belief-time": None, + # "m_viewpoints": 1, + # }, + # ), # Case 11: start-date is given with predict-period duration = 3 days # # User expects predict-start to remain based on server now (no train-period given). @@ -417,13 +417,13 @@ # - 1 cycle, 1 belief time ( { - "start-date": "2024-12-25T00:00:00+01:00", + # "start-date": "2024-12-25T00:00:00+01:00", "duration": "P3D", }, { - "start-date": pd.Timestamp( - "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" - ), + # "start-date": pd.Timestamp( + # "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" + # ), "predict-start": pd.Timestamp( "2025-01-15T12:23:58.387422+01", tz="Europe/Amsterdam", @@ -433,14 +433,14 @@ ) + pd.Timedelta(days=3), "predict-period-in-hours": 72, - "train-period-in-hours": 516, # from start-date to predict-start + # "train-period-in-hours": 516, # from start-date to predict-start "max-forecast-horizon": pd.Timedelta( days=3 ), # duration between predict-start and end-date "forecast-frequency": pd.Timedelta(days=3), # default values # "retrain_frequency": 2 * 24, - "max-training-period": pd.Timedelta(days=365), + # "max-training-period": pd.Timedelta(days=365), # server now "save-belief-time": pd.Timestamp( "2025-01-15T12:23:58.387422+01", @@ -460,36 +460,36 @@ # - forecast-frequency = predict-period = 3 days # - retrain-frequency = FM planning horizon # - 1 cycle, 1 belief time - ( - { - "start-date": "2024-12-01T00:00:00+01:00", - "train-period": "P20D", - "duration": "P3D", - }, - { - "start-date": pd.Timestamp( - "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - ), - "predict-start": pd.Timestamp( - "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=20), - "end-date": pd.Timestamp( - "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" - ) - + pd.Timedelta(days=23), - "train-period-in-hours": 480, - "predict-period-in-hours": 72, - # defaults to prediction period (duration) - "max-forecast-horizon": pd.Timedelta(days=3), - "forecast-frequency": pd.Timedelta(days=3), - # default values - # "retrain_frequency": 2 * 24, - "max-training-period": pd.Timedelta(days=365), - # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency - "save-belief-time": None, - }, - ), + # ( + # { + # # "start-date": "2024-12-01T00:00:00+01:00", + # # "train-period": "P20D", + # "duration": "P3D", + # }, + # { + # # "start-date": pd.Timestamp( + # # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + # # ), + # "predict-start": pd.Timestamp( + # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(days=20), + # "end-date": pd.Timestamp( + # "2024-12-01T00:00:00+01", tz="Europe/Amsterdam" + # ) + # + pd.Timedelta(days=23), + # # "train-period-in-hours": 480, + # "predict-period-in-hours": 72, + # # defaults to prediction period (duration) + # "max-forecast-horizon": pd.Timedelta(days=3), + # "forecast-frequency": pd.Timedelta(days=3), + # # default values + # # "retrain_frequency": 2 * 24, + # # "max-training-period": pd.Timedelta(days=365), + # # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency + # "save-belief-time": None, + # }, + # ), # Case 13: only end-date is given with retrain-frequency = 3 days # # User expects train start and predict start to be derived from end-date and defaults. @@ -556,18 +556,18 @@ "predict-start": pd.Timestamp( "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" ), - "start-date": pd.Timestamp( - "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" - ) - - pd.Timedelta(days=30), + # "start-date": pd.Timestamp( + # "2025-01-15T12:00:00+01", tz="Europe/Amsterdam" + # ) + # - pd.Timedelta(days=30), "predict-period-in-hours": 240, # from predict start to end date - "train-period-in-hours": 30 * 24, + # "train-period-in-hours": 30 * 24, "max-forecast-horizon": pd.Timedelta( days=10 ), # duration between predict start and end date "forecast-frequency": pd.Timedelta(hours=120), # default values - "max-training-period": pd.Timedelta(days=365), + # "max-training-period": pd.Timedelta(days=365), # "retrain-frequency": 2 * 24, # server now "save-belief-time": pd.Timestamp( diff --git a/flexmeasures/data/tests/test_forecasting_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py index 3ee635be5a..ad988af67e 100644 --- a/flexmeasures/data/tests/test_forecasting_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -20,15 +20,15 @@ ( { # "model": "CustomLGBM", + "start-date": "2025-01-01T00:00+02:00", + "train-period": "P2D", "retrain-frequency": "P0D", # 0 days is expected to fail }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-date": "2025-01-01T00:00+02:00", "end-date": "2025-01-03T00:00+02:00", - "train-period": "P2D", "sensor-to-save": None, "start-predict-date": "2025-01-02T00:00+02:00", "max-forecast-horizon": "PT1H", @@ -42,12 +42,12 @@ { # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], + "start-date": "2025-01-01T00:00+02:00", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-date": "2025-01-01T00:00+02:00", "start-predict-date": "2025-01-08T00:00+02:00", # start-predict-date coincides with end of available data in sensor "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, @@ -62,13 +62,13 @@ { # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], + # "start-date": "2025-01-01T00:00+02:00", # without a start date, max-training-period takes over + "max-training-period": "P7D", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - # "start-date": "2025-01-01T00:00+02:00", # without a start date, max-training-period takes over - "max-training-period": "P7D", "start-predict-date": "2025-01-08T00:00+02:00", # start-predict-date coincides with end of available data in sensor "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, @@ -84,12 +84,12 @@ # "model": "CustomLGBM", "past-regressors": ["irradiance-sensor"], "future-regressors": ["irradiance-sensor"], + "start-date": "2025-01-01T00:00+02:00", }, { # Test: duplicate sensor names in past and future regressors "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-date": "2025-01-01T00:00+02:00", "start-predict-date": "2025-01-08T00:00+02:00", "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, @@ -105,14 +105,14 @@ # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], "retrain-frequency": "P1D", + "start-date": "2025-01-01T00:00+02:00", + "train-period": "P2D", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-date": "2025-01-01T00:00+02:00", "end-date": "2025-01-03T00:00+02:00", - "train-period": "P2D", "sensor-to-save": None, "start-predict-date": "2025-01-02T00:00+02:00", "max-forecast-horizon": "PT1H", @@ -263,11 +263,11 @@ def test_train_predict_pipeline( # noqa: C901 assert ( "regressors" not in data_generator_config ), "(past and future) regressors should be stored under 'past_regressors' and 'future_regressors' instead" + assert "max-training-period" in data_generator_config # Check DataGenerator parameters stored under DataSource attributes is empty data_generator_params = source.attributes["data_generator"]["parameters"] - # todo: replace this with `assert data_generator_params == {}` after moving max-training-period to config - assert "max-training-period" in data_generator_params + assert data_generator_params == {} # Test that missing data logging works and raises NotEnoughDataException when threshold exceeded @@ -278,12 +278,12 @@ def test_train_predict_pipeline( # noqa: C901 { # "model": "CustomLGBM", "missing-threshold": "0.0", + "start-date": "2025-01-01T00:00+02:00", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-date": "2025-01-01T00:00+02:00", "end-date": "2025-01-30T00:00+02:00", "sensor-to-save": None, "start-predict-date": "2025-01-25T00:00+02:00", @@ -298,12 +298,12 @@ def test_train_predict_pipeline( # noqa: C901 # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], "missing-threshold": "0.0", + "start-date": "2025-01-01T00:00+02:00", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-date": "2025-01-01T00:00+02:00", "end-date": "2025-01-30T00:00+02:00", "sensor-to-save": None, "start-predict-date": "2025-01-25T00:00+02:00", @@ -363,14 +363,14 @@ def test_missing_data_logs_warning( { # "model": "CustomLGBM", "retrain-frequency": "P1D", + "start-date": "2025-01-01T00:00+02:00", + "max-training-period": "P10D", # cap at 10 days }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-date": "2025-01-01T00:00+02:00", "end-date": "2025-01-30T00:00+02:00", - "max-training-period": "P10D", # cap at 10 days "sensor-to-save": None, "start-predict-date": "2025-01-25T00:00+02:00", "max-forecast-horizon": "PT1H", @@ -405,6 +405,6 @@ def test_train_period_capped_logs_warning( params_used = pipeline._parameters config_used = pipeline._config assert config_used["missing_threshold"] == 1 - assert params_used["train_period_in_hours"] == timedelta(days=10) / timedelta( + assert config_used["train_period_in_hours"] == timedelta(days=10) / timedelta( hours=1 ), "train_period_in_hours should be capped to max_training_period" From 3eba171c783e5b7af5d81543b81f7b9097dbbc0b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 21 Feb 2026 09:49:30 +0100 Subject: [PATCH 110/141] refactor: do not store any forecaster parameters by default Signed-off-by: F.N. Claessen --- flexmeasures/data/models/forecasting/__init__.py | 2 +- .../data/models/forecasting/pipelines/train_predict.py | 2 +- flexmeasures/data/tests/test_forecasting_pipeline.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/flexmeasures/data/models/forecasting/__init__.py b/flexmeasures/data/models/forecasting/__init__.py index abcef2a02c..5aa7683d5f 100644 --- a/flexmeasures/data/models/forecasting/__init__.py +++ b/flexmeasures/data/models/forecasting/__init__.py @@ -142,7 +142,7 @@ def _clean_parameters(self, parameters: dict) -> dict: "sensor-to-save", "as-job", "m_viewpoints", # Computed internally, still uses snake_case - "sensor", # todo: forecaster parameters should not be saved by default altogether + "sensor", ] for field in fields_to_remove: diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index ac8b49c7df..0266accb08 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -34,7 +34,7 @@ def __init__( config: dict | None = None, delete_model: bool = False, save_config: bool = True, - save_parameters: bool = True, + save_parameters: bool = False, ): super().__init__( config=config, save_config=save_config, save_parameters=save_parameters diff --git a/flexmeasures/data/tests/test_forecasting_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py index ad988af67e..dc5470ba65 100644 --- a/flexmeasures/data/tests/test_forecasting_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -266,8 +266,7 @@ def test_train_predict_pipeline( # noqa: C901 assert "max-training-period" in data_generator_config # Check DataGenerator parameters stored under DataSource attributes is empty - data_generator_params = source.attributes["data_generator"]["parameters"] - assert data_generator_params == {} + assert "parameters" not in source.attributes["data_generator"] # Test that missing data logging works and raises NotEnoughDataException when threshold exceeded From 342d6e5ac4b96c90c2209c543d6ce6b4a5d47f3f Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 21 Feb 2026 09:55:37 +0100 Subject: [PATCH 111/141] refactor: move derivation of training period into class method, and add a docstring Signed-off-by: F.N. Claessen --- .../forecasting/pipelines/train_predict.py | 52 +++++++++++++------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 0266accb08..304f159788 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -150,25 +150,21 @@ def _compute_forecast(self, as_job: bool = False, **kwargs) -> list[dict[str, An # Run the train-and-predict pipeline return self.run(as_job=as_job, **kwargs) - def run( - self, - as_job: bool = False, - queue: str = "forecasting", - **job_kwargs, - ): - logging.info( - f"Starting Train-Predict Pipeline to predict for {self._parameters['predict_period_in_hours']} hours." - ) - # How much to move forward to the next cycle one prediction period later - cycle_frequency = max( - self._config["retrain_frequency"], - self._parameters["forecast_frequency"], - ) + def _derive_training_period(self) -> tuple[datetime, datetime]: + """Derive the effective training period for model fitting. - predict_start = self._parameters["predict_start"] - predict_end = predict_start + cycle_frequency + The training period ends at ``predict_start`` and starts at the + most restrictive (latest) of the following: - # Determine training window (start, end) + - The configured ``start_date`` (if any) + - ``predict_start - train_period_in_hours`` (if configured) + - ``predict_start - max_training_period`` (always enforced) + + Additionally, the resulting training window is guaranteed to span + at least two days. + + :return: A tuple ``(train_start, train_end)`` defining the training window. + """ train_start = self._config.get("start_date", None) training_period_in_hours = self._config.get("train_period_in_hours", None) training_period = ( @@ -190,6 +186,28 @@ def run( min_training_period = timedelta(days=2) if train_end - train_start < min_training_period: train_start = train_end - min_training_period + return train_start, train_end + + def run( + self, + as_job: bool = False, + queue: str = "forecasting", + **job_kwargs, + ): + logging.info( + f"Starting Train-Predict Pipeline to predict for {self._parameters['predict_period_in_hours']} hours." + ) + # How much to move forward to the next cycle one prediction period later + cycle_frequency = max( + self._config["retrain_frequency"], + self._parameters["forecast_frequency"], + ) + + predict_start = self._parameters["predict_start"] + predict_end = predict_start + cycle_frequency + + # Determine training window (start, end) + train_start, train_end = self._derive_training_period() sensor_resolution = self._parameters["sensor"].event_resolution multiplier = int( From c3291aef2988a62917504e4981857ae387b86085 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 21 Feb 2026 10:15:55 +0100 Subject: [PATCH 112/141] refactor: simplify logic for deriving the training period Signed-off-by: F.N. Claessen --- .../forecasting/pipelines/train_predict.py | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 304f159788..80287698b6 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -165,27 +165,29 @@ def _derive_training_period(self) -> tuple[datetime, datetime]: :return: A tuple ``(train_start, train_end)`` defining the training window. """ - train_start = self._config.get("start_date", None) - training_period_in_hours = self._config.get("train_period_in_hours", None) - training_period = ( - timedelta(hours=training_period_in_hours) - if training_period_in_hours is not None - else None - ) - if training_period is not None: - train_start_from_training_period = predict_start - training_period - if train_start is None: - train_start = train_start_from_training_period - else: - train_start = max(train_start, train_start_from_training_period) - train_start_from_max_training_period = ( - predict_start - self._config["max_training_period"] - ) - train_start = max(train_start, train_start_from_max_training_period) - train_end = predict_start + train_end = self._parameters["predict_start"] + + configured_start: datetime | None = self._config.get("start_date") + period_hours: int | None = self._config.get("train_period_in_hours") + + candidates: list[datetime] = [] + + if configured_start is not None: + candidates.append(configured_start) + + if period_hours is not None: + candidates.append(train_end - timedelta(hours=period_hours)) + + # Always enforce maximum training period + candidates.append(train_end - self._config["max_training_period"]) + + train_start = max(candidates) + + # Enforce minimum training period of 2 days min_training_period = timedelta(days=2) if train_end - train_start < min_training_period: train_start = train_end - min_training_period + return train_start, train_end def run( From de2256e10df27c91067234d4dc5ae4afa654889a Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Sat, 21 Feb 2026 10:18:16 +0100 Subject: [PATCH 113/141] style: flake8 Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 1 - flexmeasures/data/tests/test_forecasting_pipeline.py | 1 - 2 files changed, 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index df3b4fcdd7..a9c8558e90 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -10,7 +10,6 @@ fields, Schema, validates_schema, - validates, pre_load, post_load, ValidationError, diff --git a/flexmeasures/data/tests/test_forecasting_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py index dc5470ba65..38456585a4 100644 --- a/flexmeasures/data/tests/test_forecasting_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -401,7 +401,6 @@ def test_train_period_capped_logs_warning( for message in caplog.messages ), "Expected warning about capping train_period" - params_used = pipeline._parameters config_used = pipeline._config assert config_used["missing_threshold"] == 1 assert config_used["train_period_in_hours"] == timedelta(days=10) / timedelta( From ca193ddaaa0b1e43abbfa70c5f5e5aa1ce8fb040 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 10:58:01 +0100 Subject: [PATCH 114/141] chore: update openapi-specs.json Signed-off-by: F.N. Claessen --- flexmeasures/ui/static/openapi-specs.json | 36 +++++++++++++++++------ 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index d7d4377d30..bb0bf22a65 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4154,6 +4154,33 @@ "description": "Whether to clip negative values in forecasts. Defaults to None (disabled).", "example": true }, + "start-date": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "Timestamp marking the start of training data. Defaults to train_period before start_predict_date if not set.", + "example": "2025-01-01T00:00:00+01:00" + }, + "train-period": { + "type": [ + "string", + "null" + ], + "default": "P30D", + "description": "Duration of the initial training period (ISO 8601 format, min 2 days). If not set, derived from start_date and start_predict_date or defaults to P30D (30 days).", + "example": "P7D" + }, + "max-training-period": { + "type": [ + "string", + "null" + ], + "default": "P365D", + "description": "Maximum duration of the training period. Defaults to 1 year (P1Y).", + "example": "P1Y" + }, "retrain-frequency": { "type": [ "string", @@ -4168,15 +4195,6 @@ "forecasting_trigger_schema_openAPI": { "type": "object", "properties": { - "start-date": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Timestamp marking the start of training data. Defaults to train_period before start_predict_date if not set.", - "example": "2025-01-01T00:00:00+01:00" - }, "duration": { "type": "string", "description": "The duration for which to create the forecast, also known as the planning horizon, in ISO 8601 duration format.", From 6ba606a475087a3948a9014984dd218855efcbf5 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 11:21:04 +0100 Subject: [PATCH 115/141] fix: remove CLI-only fields from nested config, too Signed-off-by: F.N. Claessen --- flexmeasures/api/common/schemas/utils.py | 20 ++++++++++++++++++-- flexmeasures/ui/static/openapi-specs.json | 12 ++---------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/flexmeasures/api/common/schemas/utils.py b/flexmeasures/api/common/schemas/utils.py index 4f46ef9f81..11cdc6d220 100644 --- a/flexmeasures/api/common/schemas/utils.py +++ b/flexmeasures/api/common/schemas/utils.py @@ -4,7 +4,10 @@ from marshmallow import Schema, fields from flexmeasures.utils.doc_utils import rst_to_openapi -from flexmeasures.data.schemas.forecasting.pipeline import ForecastingTriggerSchema +from flexmeasures.data.schemas.forecasting.pipeline import ( + ForecastingTriggerSchema, + TrainPredictPipelineConfigSchema, +) from flexmeasures.data.schemas.sensors import ( SensorReferenceSchema, VariableQuantityField, @@ -28,11 +31,24 @@ def make_openapi_compatible(schema_cls: Type[Schema]) -> Type[Schema]: new_fields = {} for name, field in schema_cls._declared_fields.items(): - if schema_cls == ForecastingTriggerSchema: + if schema_cls in (ForecastingTriggerSchema, TrainPredictPipelineConfigSchema): if "cli" in field.metadata and field.metadata["cli"].get( "cli-exclusive", False ): continue + if isinstance(field, fields.Nested): + nested_schema_cls = type(field.schema) + if nested_schema_cls is TrainPredictPipelineConfigSchema: + field_copy = fields.Nested( + make_openapi_compatible(nested_schema_cls), + metadata=field.metadata, + data_key=field.data_key, + many=field.many, + required=field.required, + allow_none=field.allow_none, + ) + new_fields[name] = field_copy + continue # Copy metadata, but sanitize description for OpenAPI metadata = dict(getattr(field, "metadata", {})) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index bb0bf22a65..42a8b98e80 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4096,7 +4096,7 @@ }, "additionalProperties": false }, - "TrainPredictPipelineConfig": { + "TrainPredictPipelineConfigSchemaOpenAPI": { "type": "object", "properties": { "model": { @@ -4180,14 +4180,6 @@ "default": "P365D", "description": "Maximum duration of the training period. Defaults to 1 year (P1Y).", "example": "P1Y" - }, - "retrain-frequency": { - "type": [ - "string", - "null" - ], - "description": "Frequency of retraining/prediction cycle (ISO 8601 duration). Defaults to prediction window length if not set.", - "example": "PT24H" } }, "additionalProperties": false @@ -4236,7 +4228,7 @@ }, "config": { "description": "Changing any of these will result in a new data source ID.", - "$ref": "#/components/schemas/TrainPredictPipelineConfig" + "$ref": "#/components/schemas/TrainPredictPipelineConfigSchemaOpenAPI" } }, "additionalProperties": false From 6b08dfeab44e86dc8941d9532cdc63d7ac1cb02b Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 11:23:17 +0100 Subject: [PATCH 116/141] feat: remove end-date and training timing fields from API docs Signed-off-by: F.N. Claessen --- .../data/schemas/forecasting/pipeline.py | 4 +++ flexmeasures/ui/static/openapi-specs.json | 36 ------------------- 2 files changed, 4 insertions(+), 36 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index a9c8558e90..df1347b888 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -105,6 +105,7 @@ class TrainPredictPipelineConfigSchema(Schema): "description": "Timestamp marking the start of training data. Defaults to train_period before start_predict_date if not set.", "example": "2025-01-01T00:00:00+01:00", "cli": { + "cli-exclusive": True, "option": "--start-date", "aliases": ["--train-start"], }, @@ -118,6 +119,7 @@ class TrainPredictPipelineConfigSchema(Schema): "description": "Duration of the initial training period (ISO 8601 format, min 2 days). If not set, derived from start_date and start_predict_date or defaults to P30D (30 days).", "example": "P7D", "cli": { + "cli-exclusive": True, "option": "--train-period", }, }, @@ -130,6 +132,7 @@ class TrainPredictPipelineConfigSchema(Schema): "description": "Maximum duration of the training period. Defaults to 1 year (P1Y).", "example": "P1Y", "cli": { + "cli-exclusive": True, "option": "--max-training-period", }, }, @@ -259,6 +262,7 @@ class ForecasterParametersSchema(Schema): "description": "End date for running the pipeline.", "example": "2025-10-15T00:00:00+01:00", "cli": { + "cli-exclusive": True, "option": "--end-date", "aliases": ["--to-date"], }, diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 42a8b98e80..1d035caeaa 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4153,33 +4153,6 @@ "default": false, "description": "Whether to clip negative values in forecasts. Defaults to None (disabled).", "example": true - }, - "start-date": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "Timestamp marking the start of training data. Defaults to train_period before start_predict_date if not set.", - "example": "2025-01-01T00:00:00+01:00" - }, - "train-period": { - "type": [ - "string", - "null" - ], - "default": "P30D", - "description": "Duration of the initial training period (ISO 8601 format, min 2 days). If not set, derived from start_date and start_predict_date or defaults to P30D (30 days).", - "example": "P7D" - }, - "max-training-period": { - "type": [ - "string", - "null" - ], - "default": "P365D", - "description": "Maximum duration of the training period. Defaults to 1 year (P1Y).", - "example": "P1Y" } }, "additionalProperties": false @@ -4192,15 +4165,6 @@ "description": "The duration for which to create the forecast, also known as the planning horizon, in ISO 8601 duration format.", "example": "PT24H" }, - "end-date": { - "type": [ - "string", - "null" - ], - "format": "date-time", - "description": "End date for running the pipeline.", - "example": "2025-10-15T00:00:00+01:00" - }, "start-predict-date": { "type": [ "string", From e1dd3fbf14b30e045a42cd3235a239519832c7a4 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 11:29:15 +0100 Subject: [PATCH 117/141] refactor: rename start-predict-date to start Signed-off-by: Mohamed Belhsan Hmida --- .../api/v3_0/tests/test_forecasting_api.py | 2 +- .../forecasting/pipelines/train_predict.py | 2 +- .../data/schemas/forecasting/pipeline.py | 20 +++++++++---------- .../data/schemas/tests/test_forecasting.py | 6 +++--- .../data/tests/test_forecasting_pipeline.py | 16 +++++++-------- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index 3f51aad8a3..3493ffb6ef 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -33,7 +33,7 @@ def test_trigger_and_fetch_forecasts( # Trigger job payload = { - "start-predict-date": "2025-01-05T00:00:00+00:00", + "start": "2025-01-05T00:00:00+00:00", "end-date": "2025-01-05T02:00:00+00:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 80287698b6..e903139257 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -261,7 +261,7 @@ def run( # job metadata for tracking job_metadata = { "data_source_info": {"id": self.data_source.id}, - "start_predict_date": self._parameters["predict_start"], + "start": self._parameters["start"], "end_date": self._parameters["end_date"], "sensor_id": self._parameters["sensor_to_save"].id, } diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index df1347b888..b82a9fe2c4 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -268,16 +268,16 @@ class ForecasterParametersSchema(Schema): }, }, ) - start_predict_date = AwareDateTimeOrDateField( - data_key="start-predict-date", + start = AwareDateTimeOrDateField( + data_key="start", required=False, allow_none=True, metadata={ "description": "Start date for predictions. Defaults to now, floored to the sensor resolution, so that the first forecast is about the ongoing event.", "example": "2025-01-08T00:00:00+01:00", "cli": { - "option": "--start-predict-date", - "aliases": ["--from-date"], + "option": "--start", + "aliases": ["--start-predict-date", "--from-date"], }, }, ) @@ -348,7 +348,7 @@ def sanitize_input(self, data, **kwargs): @validates_schema def validate_parameters(self, data: dict, **kwargs): # noqa: C901 end_date = data.get("end_date") - predict_start = data.get("start_predict_date", None) + predict_start = data.get("start", None) max_forecast_horizon = data.get("max_forecast_horizon") forecast_frequency = data.get("forecast_frequency") sensor = data.get("sensor") @@ -367,8 +367,8 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 # ) if end_date is not None and predict_start >= end_date: raise ValidationError( - "start-predict-date must be before end-date", - field_name="start_predict_date", + "start must be before end", + field_name="start", ) if max_forecast_horizon is not None: @@ -406,16 +406,16 @@ def resolve_config( # noqa: C901 now = server_now() floored_now = floor_to_resolution(now, resolution) - if data.get("start_predict_date") is None: + if data.get("start") is None: if original_data.get("duration") and data.get("end_date") is not None: predict_start = data["end_date"] - data["duration"] else: predict_start = floored_now else: - predict_start = data["start_predict_date"] + predict_start = data["start"] save_belief_time = ( - now if data.get("start_predict_date") is None else predict_start + now if data.get("start") is None else predict_start ) if data.get("end_date") is None: diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 5483e7c75e..5b4104eadc 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -132,7 +132,7 @@ # - 1 cycle, 4 belief times ( { - "start-predict-date": "2025-01-15T12:00:00+01:00", + "start": "2025-01-15T12:00:00+01:00", "forecast-frequency": "PT12H", }, { @@ -398,7 +398,7 @@ # "predict-period-in-hours": 48, # # "retrain_frequency": 2 * 24, # # "max-training-period": pd.Timedelta(days=365), - # # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency + # # the belief time of the forecasts will be calculated from start and max-forecast-horizon and forecast-frequency # "save-belief-time": None, # "m_viewpoints": 1, # }, @@ -486,7 +486,7 @@ # # default values # # "retrain_frequency": 2 * 24, # # "max-training-period": pd.Timedelta(days=365), - # # the belief time of the forecasts will be calculated from start-predict-date and max-forecast-horizon and forecast-frequency + # # the belief time of the forecasts will be calculated from start and max-forecast-horizon and forecast-frequency # "save-belief-time": None, # }, # ), diff --git a/flexmeasures/data/tests/test_forecasting_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py index 38456585a4..ca2a6238fb 100644 --- a/flexmeasures/data/tests/test_forecasting_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -30,7 +30,7 @@ "output-path": None, "end-date": "2025-01-03T00:00+02:00", "sensor-to-save": None, - "start-predict-date": "2025-01-02T00:00+02:00", + "start": "2025-01-02T00:00+02:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "probabilistic": False, @@ -48,7 +48,7 @@ "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-predict-date": "2025-01-08T00:00+02:00", # start-predict-date coincides with end of available data in sensor + "start": "2025-01-08T00:00+02:00", # start coincides with end of available data in sensor "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", @@ -69,7 +69,7 @@ "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-predict-date": "2025-01-08T00:00+02:00", # start-predict-date coincides with end of available data in sensor + "start": "2025-01-08T00:00+02:00", # start coincides with end of available data in sensor "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", @@ -90,7 +90,7 @@ "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "start-predict-date": "2025-01-08T00:00+02:00", + "start": "2025-01-08T00:00+02:00", "end-date": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", @@ -114,7 +114,7 @@ "output-path": None, "end-date": "2025-01-03T00:00+02:00", "sensor-to-save": None, - "start-predict-date": "2025-01-02T00:00+02:00", + "start": "2025-01-02T00:00+02:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT24H", "probabilistic": False, @@ -285,7 +285,7 @@ def test_train_predict_pipeline( # noqa: C901 "output-path": None, "end-date": "2025-01-30T00:00+02:00", "sensor-to-save": None, - "start-predict-date": "2025-01-25T00:00+02:00", + "start": "2025-01-25T00:00+02:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "probabilistic": False, @@ -305,7 +305,7 @@ def test_train_predict_pipeline( # noqa: C901 "output-path": None, "end-date": "2025-01-30T00:00+02:00", "sensor-to-save": None, - "start-predict-date": "2025-01-25T00:00+02:00", + "start": "2025-01-25T00:00+02:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "probabilistic": False, @@ -371,7 +371,7 @@ def test_missing_data_logs_warning( "output-path": None, "end-date": "2025-01-30T00:00+02:00", "sensor-to-save": None, - "start-predict-date": "2025-01-25T00:00+02:00", + "start": "2025-01-25T00:00+02:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "probabilistic": False, From 0f31621df98f877722612387dc6cc5a4169042b9 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 11:54:06 +0100 Subject: [PATCH 118/141] refactor: change start-date to train-start Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 2 +- flexmeasures/data/schemas/forecasting/pipeline.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index 3493ffb6ef..2f5ddfb8f8 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -38,7 +38,7 @@ def test_trigger_and_fetch_forecasts( "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "config": { - "start-date": "2025-01-01T00:00:00+00:00", + "train-start": "2025-01-01T00:00:00+00:00", "retrain-frequency": "PT1H", }, } diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index b82a9fe2c4..91d4fe0c37 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -97,17 +97,17 @@ class TrainPredictPipelineConfigSchema(Schema): }, }, ) - start_date = AwareDateTimeOrDateField( - data_key="start-date", + train_start = AwareDateTimeOrDateField( + data_key="train-start", required=False, allow_none=True, metadata={ - "description": "Timestamp marking the start of training data. Defaults to train_period before start_predict_date if not set.", + "description": "Timestamp marking the start of training data. Defaults to train_period before start if not set.", "example": "2025-01-01T00:00:00+01:00", "cli": { "cli-exclusive": True, - "option": "--start-date", - "aliases": ["--train-start"], + "option": "--train-start", + "aliases": ["--start-date", "--train-start"], }, }, ) @@ -116,7 +116,7 @@ class TrainPredictPipelineConfigSchema(Schema): load_default=timedelta(days=30), allow_none=True, metadata={ - "description": "Duration of the initial training period (ISO 8601 format, min 2 days). If not set, derived from start_date and start_predict_date or defaults to P30D (30 days).", + "description": "Duration of the initial training period (ISO 8601 format, min 2 days). If not set, derived from train_start and start if not set or defaults to P30D (30 days).", "example": "P7D", "cli": { "cli-exclusive": True, From a355761cd077efa6cc9a8be48749d30ac4c49eec Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 11:55:31 +0100 Subject: [PATCH 119/141] refactor: change end-date to end Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/forecasting/pipeline.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 91d4fe0c37..38d7429c41 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -253,18 +253,18 @@ class ForecasterParametersSchema(Schema): example="PT24H", ), ) - end_date = AwareDateTimeOrDateField( - data_key="end-date", + end = AwareDateTimeOrDateField( + data_key="end", required=False, allow_none=True, inclusive=True, metadata={ - "description": "End date for running the pipeline.", + "description": "last event start of forecasts generated.", "example": "2025-10-15T00:00:00+01:00", "cli": { "cli-exclusive": True, - "option": "--end-date", - "aliases": ["--to-date"], + "option": "--end", + "aliases": ["--end-date", "--to-date"], }, }, ) @@ -347,7 +347,7 @@ def sanitize_input(self, data, **kwargs): @validates_schema def validate_parameters(self, data: dict, **kwargs): # noqa: C901 - end_date = data.get("end_date") + end_date = data.get("end") predict_start = data.get("start", None) max_forecast_horizon = data.get("max_forecast_horizon") forecast_frequency = data.get("forecast_frequency") @@ -418,12 +418,12 @@ def resolve_config( # noqa: C901 now if data.get("start") is None else predict_start ) - if data.get("end_date") is None: - data["end_date"] = predict_start + data["duration"] + if data.get("end") is None: + data["end"] = predict_start + data["duration"] predict_period = ( - data["end_date"] - predict_start - if data.get("end_date") + data["end"] - predict_start + if data.get("end") else data["duration"] ) forecast_frequency = data.get("forecast_frequency") From 4f8eb278507cb76d6fe1151bb02a9ae805236f0b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 11:55:47 +0100 Subject: [PATCH 120/141] refactor: update forecasting job JSON keys to match new naming conventions Signed-off-by: Mohamed Belhsan Hmida --- documentation/tut/forecasting_scheduling.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/tut/forecasting_scheduling.rst b/documentation/tut/forecasting_scheduling.rst index 4d6e043ca9..80f4008342 100644 --- a/documentation/tut/forecasting_scheduling.rst +++ b/documentation/tut/forecasting_scheduling.rst @@ -104,9 +104,9 @@ There are two ways to queue a forecasting job: .. code-block:: json { - "start_date": "2025-01-01T00:00:00+00:00", - "start_predict_date": "2025-01-04T00:00:00+00:00", - "end_date": "2025-01-04T04:00:00+00:00" + "train_start": "2025-01-01T00:00:00+00:00", + "start": "2025-01-04T00:00:00+00:00", + "end": "2025-01-04T04:00:00+00:00" } Example response: From 7630e121ff6b684f35043d3d1e65e0624a18b7b2 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 11:56:01 +0100 Subject: [PATCH 121/141] refactor: update forecasting trigger schema keys to match new naming conventions Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/api/v3_0/sensors.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 0b66bc31ce..ff42a87e4e 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1573,9 +1573,9 @@ def trigger_forecast(self, id: int, **params): application/json: schema: forecasting_trigger_schema_openAPI example: - start-date: "2026-01-01T00:00:00+01:00" - start-predict-date: "2026-01-15T00:00:00+01:00" - end-date: "2026-01-17T00:00:00+01:00" + train-start: "2026-01-01T00:00:00+01:00" + start: "2026-01-15T00:00:00+01:00" + end: "2026-01-17T00:00:00+01:00" responses: 200: description: PROCESSED From fdcd8ca0795798f740cf35554b4aabe2f789eb6e Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 11:56:28 +0100 Subject: [PATCH 122/141] refactor: update job metadata keys to match new naming conventions Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/models/forecasting/pipelines/train_predict.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index e903139257..9cfe53e023 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -262,7 +262,7 @@ def run( job_metadata = { "data_source_info": {"id": self.data_source.id}, "start": self._parameters["start"], - "end_date": self._parameters["end_date"], + "end": self._parameters["date"], "sensor_id": self._parameters["sensor_to_save"].id, } for cycle_params in cycles_job_params: From 2ddec9a32e90a930a5507b7ec779ac91d0f6a93d Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 11:56:40 +0100 Subject: [PATCH 123/141] refactor: update forecasting job metadata keys to match new naming conventions Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/api/v3_0/sensors.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index ff42a87e4e..bdd1c15ab2 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1793,8 +1793,8 @@ def get_forecast(self, id: int, uuid: str, sensor: Sensor, job_id: str): data_source = get_data_source_for_job(job, type="forecasting") forecasts = sensor.search_beliefs( - event_starts_after=job.meta.get("start_predict_date"), - event_ends_before=job.meta.get("end_date"), + event_starts_after=job.meta.get("start"), + event_ends_before=job.meta.get("end"), source=data_source, most_recent_beliefs_only=True, use_latest_version_per_event=True, From 82873556480b72f977a8ce5cd061b8e1443767ef Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 12:04:14 +0100 Subject: [PATCH 124/141] refactor: update forecasting parameter keys to match new naming conventions Signed-off-by: Mohamed Belhsan Hmida --- .../data/models/forecasting/pipelines/train_predict.py | 6 +++--- flexmeasures/data/schemas/forecasting/pipeline.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index 9cfe53e023..f262fe915b 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -167,7 +167,7 @@ def _derive_training_period(self) -> tuple[datetime, datetime]: """ train_end = self._parameters["predict_start"] - configured_start: datetime | None = self._config.get("start_date") + configured_start: datetime | None = self._config.get("train_start") period_hours: int | None = self._config.get("train_period_in_hours") candidates: list[datetime] = [] @@ -261,8 +261,8 @@ def run( # job metadata for tracking job_metadata = { "data_source_info": {"id": self.data_source.id}, - "start": self._parameters["start"], - "end": self._parameters["date"], + "start": self._parameters["predict_start"], + "end": self._parameters["end_date"], "sensor_id": self._parameters["sensor_to_save"].id, } for cycle_params in cycles_job_params: diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 38d7429c41..280d133692 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -407,8 +407,8 @@ def resolve_config( # noqa: C901 floored_now = floor_to_resolution(now, resolution) if data.get("start") is None: - if original_data.get("duration") and data.get("end_date") is not None: - predict_start = data["end_date"] - data["duration"] + if original_data.get("duration") and data.get("end") is not None: + predict_start = data["end"] - data["duration"] else: predict_start = floored_now else: @@ -481,7 +481,7 @@ def resolve_config( # noqa: C901 sensor=target_sensor, model_save_dir=model_save_dir, output_path=output_path, - end_date=data["end_date"], + end_date=data["end"], predict_start=predict_start, predict_period_in_hours=predict_period_in_hours, max_forecast_horizon=max_forecast_horizon, From 4976e37d751dfdb6be6649caf097aaba45e32a8f Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 12:04:44 +0100 Subject: [PATCH 125/141] refactor(test): update forecast job payload and job metadata keys to match new naming conventions Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/api/v3_0/tests/test_forecasting_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flexmeasures/api/v3_0/tests/test_forecasting_api.py b/flexmeasures/api/v3_0/tests/test_forecasting_api.py index 2f5ddfb8f8..cc7a9afb92 100644 --- a/flexmeasures/api/v3_0/tests/test_forecasting_api.py +++ b/flexmeasures/api/v3_0/tests/test_forecasting_api.py @@ -34,7 +34,7 @@ def test_trigger_and_fetch_forecasts( # Trigger job payload = { "start": "2025-01-05T00:00:00+00:00", - "end-date": "2025-01-05T02:00:00+00:00", + "end": "2025-01-05T02:00:00+00:00", "max-forecast-horizon": "PT1H", "forecast-frequency": "PT1H", "config": { @@ -106,8 +106,8 @@ def test_trigger_and_fetch_forecasts( # Load only the latest belief per event_start forecasts_df = sensor_1.search_beliefs( - event_starts_after=job.meta.get("start_predict_date"), - event_ends_before=job.meta.get("end_date"), + event_starts_after=job.meta.get("start"), + event_ends_before=job.meta.get("end"), source_types=["forecaster"], most_recent_beliefs_only=True, use_latest_version_per_event=True, From bfedd0d18e5b0fca1cd9ba207d87c779bbfc8e54 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 12:09:51 +0100 Subject: [PATCH 126/141] doc: fix comment typo Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/models/forecasting/pipelines/train_predict.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/models/forecasting/pipelines/train_predict.py b/flexmeasures/data/models/forecasting/pipelines/train_predict.py index f262fe915b..7da3a98ffd 100644 --- a/flexmeasures/data/models/forecasting/pipelines/train_predict.py +++ b/flexmeasures/data/models/forecasting/pipelines/train_predict.py @@ -214,7 +214,7 @@ def run( sensor_resolution = self._parameters["sensor"].event_resolution multiplier = int( timedelta(hours=1) / sensor_resolution - ) # multiplier used to adapt n_steps_to_predict to hours from sensor resolution, e.g. 15 min sensor resolution will have 7*24*4 = 168 predicitons to predict a week + ) # multiplier used to adapt n_steps_to_predict to hours from sensor resolution, e.g. 15 min sensor resolution will have 7*24*4 = 168 predictions to predict a week # Compute number of training cycles (at least 1) n_cycles = max( From b38f6eb5c512f44b0c413bff7e300a422393897b Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 12:11:52 +0100 Subject: [PATCH 127/141] refactor(tests): update test cases to use 'end' and 'train-start' keys Signed-off-by: Mohamed Belhsan Hmida --- .../data/schemas/tests/test_forecasting.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/flexmeasures/data/schemas/tests/test_forecasting.py b/flexmeasures/data/schemas/tests/test_forecasting.py index 5b4104eadc..ed14afa2f8 100644 --- a/flexmeasures/data/schemas/tests/test_forecasting.py +++ b/flexmeasures/data/schemas/tests/test_forecasting.py @@ -240,7 +240,7 @@ # - 1 cycle, 1 belief time # - training-period = 30 days ( - {"end-date": "2025-01-20T12:00:00+01:00"}, + {"end": "2025-01-20T12:00:00+01:00"}, { "predict-start": pd.Timestamp( "2025-01-15T12:23:58.387422+01", @@ -276,7 +276,7 @@ "m_viewpoints": 1, }, ), - # Case 8: end-date = almost 4.5 days after now, start-date is 26.5 days before now + # Case 8: end-date = almost 4.5 days after now, train-start is 26.5 days before now # # User expects to get forecasts for the next 4.5 days (from server now floored to 1 hour) with a custom 636-hour training period # - predict-period = 108 hours @@ -286,8 +286,8 @@ # - training-period = 636 hours ( { - # "start-date": "2024-12-20T00:00:00+01:00", - "end-date": "2025-01-20T00:00:00+01:00", + # "train-start": "2024-12-20T00:00:00+01:00", + "end": "2025-01-20T00:00:00+01:00", }, { # "start-date": pd.Timestamp( @@ -327,7 +327,7 @@ # - 1 cycle, 1 belief time ( { - "end-date": "2025-01-20T12:00:00+01:00", + "end": "2025-01-20T12:00:00+01:00", # "train-period": "P3D", }, { @@ -359,11 +359,11 @@ "m_viewpoints": 1, }, ), - # Case 10: start-date is given with train-period = 3 days + # Case 10: train-start is given with train-period = 3 days # - # User expects predict-start to be derived from start-date + train-period. + # User expects predict-start to be derived from train-start + train-period. # Specifically, we expect: - # - predict-start = start-date + 3 days + # - predict-start = train-start + 3 days # - predict-period = FM planning horizon (48 hours) # - end-date = predict-start + 48 hours # - max-forecast-horizon = predict-period = 48 hours @@ -372,11 +372,11 @@ # - 1 cycle, 1 belief time # ( # { - # # "start-date": "2024-12-25T00:00:00+01:00", + # # "train-start": "2024-12-25T00:00:00+01:00", # # "train-period": "P3D", # }, # { - # # "start-date": pd.Timestamp( + # # "train-start": pd.Timestamp( # # "2024-12-25T00:00:00+01", tz="Europe/Amsterdam" # # ), # "predict-start": pd.Timestamp( @@ -403,21 +403,21 @@ # "m_viewpoints": 1, # }, # ), - # Case 11: start-date is given with predict-period duration = 3 days + # Case 11: train-start is given with predict-period duration = 3 days # # User expects predict-start to remain based on server now (no train-period given). # Specifically, we expect: # - predict-start = server now floored to sensor resolution # - predict-period = 3 days # - end-date = predict-start + 3 days - # - train-period derived from start-date to predict-start + # - train-period derived from train-start to predict-start # - max-forecast-horizon = predict-period = 3 days # - forecast-frequency = predict-period = 3 days # - retrain-frequency = FM planning horizon # - 1 cycle, 1 belief time ( { - # "start-date": "2024-12-25T00:00:00+01:00", + # "train-start": "2024-12-25T00:00:00+01:00", "duration": "P3D", }, { @@ -433,7 +433,7 @@ ) + pd.Timedelta(days=3), "predict-period-in-hours": 72, - # "train-period-in-hours": 516, # from start-date to predict-start + # "train-period-in-hours": 516, # from train-start to predict-start "max-forecast-horizon": pd.Timedelta( days=3 ), # duration between predict-start and end-date @@ -449,20 +449,20 @@ "m_viewpoints": 1, }, ), - # Case 12: start-date is given with train-period = 20 days and duration = 3 days + # Case 12: train-start is given with train-period = 20 days and duration = 3 days # - # User expects both predict-start and end-date to be derived from start-date. + # User expects both predict-start and end-date to be derived from train-start. # Specifically, we expect: - # - predict-start = start-date + 20 days + # - predict-start = train-start + 20 days # - predict-period = 3 days - # - end-date = start-date + 23 days + # - end-date = train-start + 23 days # - max-forecast-horizon = predict-period = 3 days # - forecast-frequency = predict-period = 3 days # - retrain-frequency = FM planning horizon # - 1 cycle, 1 belief time # ( # { - # # "start-date": "2024-12-01T00:00:00+01:00", + # # "train-start": "2024-12-01T00:00:00+01:00", # # "train-period": "P20D", # "duration": "P3D", # }, @@ -490,13 +490,13 @@ # "save-belief-time": None, # }, # ), - # Case 13: only end-date is given with retrain-frequency = 3 days + # Case 13: only end is given with retrain-frequency = 3 days # # User expects train start and predict start to be derived from end-date and defaults. # Specifically, we expect: # - predict-start = end-date - default duration (FM planning horizon) # - train-period = default 30 days - # - start-date = predict-start - 30 days + # - train-start = predict-start - 30 days # - predict-period = 6 days # - max-forecast-horizon = predict-period = 6 days # - forecast-frequency = predict-period = 6 days From 57dd7dea03254e0b14c3119b875f269683e9af78 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 12:12:43 +0100 Subject: [PATCH 128/141] chore: update field names in comments Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/data/schemas/forecasting/pipeline.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 280d133692..a7437f757b 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -354,16 +354,16 @@ def validate_parameters(self, data: dict, **kwargs): # noqa: C901 sensor = data.get("sensor") # todo: consider moving this to the run method in train_predict.py - # if start_date is not None and end_date is not None and start_date >= end_date: + # if train_start is not None and end is not None and train_start >= end_date: # raise ValidationError( - # "start-date must be before end-date", field_name="start_date" + # "train_start must be before end", field_name="train-start" # ) if predict_start: - # if start_date is not None and predict_start < start_date: + # if train_start is not None and predict_start < train_start: # raise ValidationError( - # "start-predict-date cannot be before start-date", - # field_name="start_predict_date", + # "start cannot be before start", + # field_name="start", # ) if end_date is not None and predict_start >= end_date: raise ValidationError( From 073e9a827512cf67037107083dc5cb3432d0a304 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 12:13:28 +0100 Subject: [PATCH 129/141] refactor(tests): update date keys in test cases to match new naming conventions Signed-off-by: Mohamed Belhsan Hmida --- .../data/tests/test_forecasting_pipeline.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/flexmeasures/data/tests/test_forecasting_pipeline.py b/flexmeasures/data/tests/test_forecasting_pipeline.py index ca2a6238fb..348eff7f9a 100644 --- a/flexmeasures/data/tests/test_forecasting_pipeline.py +++ b/flexmeasures/data/tests/test_forecasting_pipeline.py @@ -20,7 +20,7 @@ ( { # "model": "CustomLGBM", - "start-date": "2025-01-01T00:00+02:00", + "train-start": "2025-01-01T00:00+02:00", "train-period": "P2D", "retrain-frequency": "P0D", # 0 days is expected to fail }, @@ -28,7 +28,7 @@ "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "end-date": "2025-01-03T00:00+02:00", + "end": "2025-01-03T00:00+02:00", "sensor-to-save": None, "start": "2025-01-02T00:00+02:00", "max-forecast-horizon": "PT1H", @@ -42,14 +42,14 @@ { # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], - "start-date": "2025-01-01T00:00+02:00", + "train-start": "2025-01-01T00:00+02:00", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, "start": "2025-01-08T00:00+02:00", # start coincides with end of available data in sensor - "end-date": "2025-01-09T00:00+02:00", + "end": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", "forecast-frequency": "PT24H", # 1 cycle and 1 viewpoint @@ -62,7 +62,7 @@ { # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], - # "start-date": "2025-01-01T00:00+02:00", # without a start date, max-training-period takes over + # "train-start": "2025-01-01T00:00+02:00", # without a start date, max-training-period takes over "max-training-period": "P7D", }, { @@ -70,7 +70,7 @@ "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, "start": "2025-01-08T00:00+02:00", # start coincides with end of available data in sensor - "end-date": "2025-01-09T00:00+02:00", + "end": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", "forecast-frequency": "PT24H", # 1 cycle and 1 viewpoint @@ -84,14 +84,14 @@ # "model": "CustomLGBM", "past-regressors": ["irradiance-sensor"], "future-regressors": ["irradiance-sensor"], - "start-date": "2025-01-01T00:00+02:00", + "train-start": "2025-01-01T00:00+02:00", }, { # Test: duplicate sensor names in past and future regressors "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, "start": "2025-01-08T00:00+02:00", - "end-date": "2025-01-09T00:00+02:00", + "end": "2025-01-09T00:00+02:00", "sensor-to-save": None, "max-forecast-horizon": "PT1H", "forecast-frequency": "PT24H", @@ -105,14 +105,14 @@ # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], "retrain-frequency": "P1D", - "start-date": "2025-01-01T00:00+02:00", + "train-start": "2025-01-01T00:00+02:00", "train-period": "P2D", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "end-date": "2025-01-03T00:00+02:00", + "end": "2025-01-03T00:00+02:00", "sensor-to-save": None, "start": "2025-01-02T00:00+02:00", "max-forecast-horizon": "PT1H", @@ -277,13 +277,13 @@ def test_train_predict_pipeline( # noqa: C901 { # "model": "CustomLGBM", "missing-threshold": "0.0", - "start-date": "2025-01-01T00:00+02:00", + "train-start": "2025-01-01T00:00+02:00", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "end-date": "2025-01-30T00:00+02:00", + "end": "2025-01-30T00:00+02:00", "sensor-to-save": None, "start": "2025-01-25T00:00+02:00", "max-forecast-horizon": "PT1H", @@ -297,13 +297,13 @@ def test_train_predict_pipeline( # noqa: C901 # "model": "CustomLGBM", "future-regressors": ["irradiance-sensor"], "missing-threshold": "0.0", - "start-date": "2025-01-01T00:00+02:00", + "train-start": "2025-01-01T00:00+02:00", }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "end-date": "2025-01-30T00:00+02:00", + "end": "2025-01-30T00:00+02:00", "sensor-to-save": None, "start": "2025-01-25T00:00+02:00", "max-forecast-horizon": "PT1H", @@ -362,14 +362,14 @@ def test_missing_data_logs_warning( { # "model": "CustomLGBM", "retrain-frequency": "P1D", - "start-date": "2025-01-01T00:00+02:00", + "train-start": "2025-01-01T00:00+02:00", "max-training-period": "P10D", # cap at 10 days }, { "sensor": "solar-sensor", "model-save-dir": "flexmeasures/data/models/forecasting/artifacts/models", "output-path": None, - "end-date": "2025-01-30T00:00+02:00", + "end": "2025-01-30T00:00+02:00", "sensor-to-save": None, "start": "2025-01-25T00:00+02:00", "max-forecast-horizon": "PT1H", From e374f639e32c8bb8e5e875806764a86a6c4f66cb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:29:39 +0100 Subject: [PATCH 130/141] style: black Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index a7437f757b..4888e3ebd7 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -414,17 +414,13 @@ def resolve_config( # noqa: C901 else: predict_start = data["start"] - save_belief_time = ( - now if data.get("start") is None else predict_start - ) + save_belief_time = now if data.get("start") is None else predict_start if data.get("end") is None: data["end"] = predict_start + data["duration"] predict_period = ( - data["end"] - predict_start - if data.get("end") - else data["duration"] + data["end"] - predict_start if data.get("end") else data["duration"] ) forecast_frequency = data.get("forecast_frequency") From 36b9a15bc0975eb24d8331a83fdeff47e787e373 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 12:31:57 +0100 Subject: [PATCH 131/141] refactor(doc): update forecasting job example to use 'duration' instead of 'end' key Signed-off-by: Mohamed Belhsan Hmida --- documentation/tut/forecasting_scheduling.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/documentation/tut/forecasting_scheduling.rst b/documentation/tut/forecasting_scheduling.rst index 80f4008342..1cc24d4cc6 100644 --- a/documentation/tut/forecasting_scheduling.rst +++ b/documentation/tut/forecasting_scheduling.rst @@ -104,9 +104,8 @@ There are two ways to queue a forecasting job: .. code-block:: json { - "train_start": "2025-01-01T00:00:00+00:00", "start": "2025-01-04T00:00:00+00:00", - "end": "2025-01-04T04:00:00+00:00" + "duration": "PT4H" } Example response: From 4d58440ee5184661f34893cc30d1a508a633fb06 Mon Sep 17 00:00:00 2001 From: Mohamed Belhsan Hmida Date: Mon, 23 Feb 2026 12:33:19 +0100 Subject: [PATCH 132/141] refactor(api): update forecasting trigger example to use 'duration' instead of 'end' key Signed-off-by: Mohamed Belhsan Hmida --- flexmeasures/api/v3_0/sensors.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index bdd1c15ab2..241530d50a 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1573,9 +1573,8 @@ def trigger_forecast(self, id: int, **params): application/json: schema: forecasting_trigger_schema_openAPI example: - train-start: "2026-01-01T00:00:00+01:00" start: "2026-01-15T00:00:00+01:00" - end: "2026-01-17T00:00:00+01:00" + duration: "Ps2D" responses: 200: description: PROCESSED From 9f16d567e74467b0b239203d496aa32e6a226496 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:05:49 +0100 Subject: [PATCH 133/141] feat: remove max-forecast-horizon field from API docs Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 1 + flexmeasures/ui/static/openapi-specs.json | 8 -------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 4888e3ebd7..ebdb6207f8 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -289,6 +289,7 @@ class ForecasterParametersSchema(Schema): "description": "Maximum forecast horizon. Defaults to covering the whole prediction period (which itself defaults to 48 hours).", "example": "PT48H", "cli": { + "cli-exclusive": True, "option": "--max-forecast-horizon", }, }, diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 1d035caeaa..73b00cbf57 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4174,14 +4174,6 @@ "description": "Start date for predictions. Defaults to now, floored to the sensor resolution, so that the first forecast is about the ongoing event.", "example": "2025-01-08T00:00:00+01:00" }, - "max-forecast-horizon": { - "type": [ - "string", - "null" - ], - "description": "Maximum forecast horizon. Defaults to covering the whole prediction period (which itself defaults to 48 hours).", - "example": "PT48H" - }, "forecast-frequency": { "type": [ "string", From 1054e3da7ed41bbfe46ad49a4270e50728d84814 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:38:29 +0100 Subject: [PATCH 134/141] docs: fix typo Signed-off-by: F.N. Claessen --- flexmeasures/api/v3_0/sensors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 241530d50a..15f446f4e7 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -1574,7 +1574,7 @@ def trigger_forecast(self, id: int, **params): schema: forecasting_trigger_schema_openAPI example: start: "2026-01-15T00:00:00+01:00" - duration: "Ps2D" + duration: "P2D" responses: 200: description: PROCESSED From ebe85faeb7341146a80ee9fbfe12333df1b5f5f3 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:39:02 +0100 Subject: [PATCH 135/141] chore: update openapi-specs.json Signed-off-by: F.N. Claessen --- flexmeasures/ui/static/openapi-specs.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 73b00cbf57..a0d4e3b517 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1192,9 +1192,8 @@ "$ref": "#/components/schemas/forecasting_trigger_schema_openAPI" }, "example": { - "start-date": "2026-01-01T00:00:00+01:00", - "start-predict-date": "2026-01-15T00:00:00+01:00", - "end-date": "2026-01-17T00:00:00+01:00" + "start": "2026-01-15T00:00:00+01:00", + "duration": "P2D" } } } @@ -4165,7 +4164,7 @@ "description": "The duration for which to create the forecast, also known as the planning horizon, in ISO 8601 duration format.", "example": "PT24H" }, - "start-predict-date": { + "start": { "type": [ "string", "null" From 93c25e0c72d85732c7f01cb167499b6f2534d0bc Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:41:55 +0100 Subject: [PATCH 136/141] docs: clarify mention of planning horizon in forecast duration field Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- flexmeasures/ui/static/openapi-specs.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index ebdb6207f8..41afbb8b10 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -249,7 +249,7 @@ class ForecasterParametersSchema(Schema): duration = PlanningDurationField( load_default=PlanningDurationField.load_default, metadata=dict( - description="The duration for which to create the forecast, also known as the planning horizon, in ISO 8601 duration format.", + description="The duration for which to create the forecast, in ISO 8601 duration format. Defaults to the planning horizon.", example="PT24H", ), ) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index a0d4e3b517..d0e183cc92 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4161,7 +4161,7 @@ "properties": { "duration": { "type": "string", - "description": "The duration for which to create the forecast, also known as the planning horizon, in ISO 8601 duration format.", + "description": "The duration for which to create the forecast, in ISO 8601 duration format. Defaults to the planning horizon.", "example": "PT24H" }, "start": { From 6570332ee700823be433459a0a2356200e1c4023 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:08:07 +0100 Subject: [PATCH 137/141] docs: clarify use case for forecast-frequency field Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- flexmeasures/ui/static/openapi-specs.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 41afbb8b10..778e220303 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -299,7 +299,7 @@ class ForecasterParametersSchema(Schema): required=False, allow_none=True, metadata={ - "description": "How often to recompute forecasts. This setting can be used to get forecasts from multiple viewpoints. Defaults to the max-forecast-horizon.", + "description": "How often to recompute forecasts. This setting can be used to get forecasts from multiple viewpoints, which is especially useful for running simulations. Defaults to the max-forecast-horizon.", "example": "PT1H", "cli": { "option": "--forecast-frequency", diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index d0e183cc92..d8d1841f2a 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4178,7 +4178,7 @@ "string", "null" ], - "description": "How often to recompute forecasts. This setting can be used to get forecasts from multiple viewpoints. Defaults to the max-forecast-horizon.", + "description": "How often to recompute forecasts. This setting can be used to get forecasts from multiple viewpoints, which is especially useful for running simulations. Defaults to the max-forecast-horizon.", "example": "PT1H" }, "config": { From 28a48837148982ad73da3b6ddf8744381fcb48d6 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:46:51 +0100 Subject: [PATCH 138/141] feat: expose duration field to the CLI Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 778e220303..8a4b54dac7 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -251,6 +251,10 @@ class ForecasterParametersSchema(Schema): metadata=dict( description="The duration for which to create the forecast, in ISO 8601 duration format. Defaults to the planning horizon.", example="PT24H", + cli={ + "option": "--duration", + "aliases": ["--predict-period"], + }, ), ) end = AwareDateTimeOrDateField( From fd0736f587d7d97426c011df69375f952f8532c7 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:48:54 +0100 Subject: [PATCH 139/141] docs: capitalize start of sentence Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index 8a4b54dac7..d29e2c396b 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -263,7 +263,7 @@ class ForecasterParametersSchema(Schema): allow_none=True, inclusive=True, metadata={ - "description": "last event start of forecasts generated.", + "description": "Last event start of forecasts generated.", "example": "2025-10-15T00:00:00+01:00", "cli": { "cli-exclusive": True, From a81acb705bfbf1042c0ef44be8b4d31886cdfb14 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 12:50:28 +0100 Subject: [PATCH 140/141] fix: (CLI) description of end field Signed-off-by: F.N. Claessen --- flexmeasures/data/schemas/forecasting/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/data/schemas/forecasting/pipeline.py b/flexmeasures/data/schemas/forecasting/pipeline.py index d29e2c396b..c60178c933 100644 --- a/flexmeasures/data/schemas/forecasting/pipeline.py +++ b/flexmeasures/data/schemas/forecasting/pipeline.py @@ -263,7 +263,7 @@ class ForecasterParametersSchema(Schema): allow_none=True, inclusive=True, metadata={ - "description": "Last event start of forecasts generated.", + "description": "End of the last event forecasted. Use either this field or the duration field.", "example": "2025-10-15T00:00:00+01:00", "cli": { "cli-exclusive": True, From 39d250e589583a7a4b7969a63b51974c41355a67 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Mon, 23 Feb 2026 13:07:05 +0100 Subject: [PATCH 141/141] style: flake8 Signed-off-by: F.N. Claessen --- flexmeasures/api/common/schemas/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flexmeasures/api/common/schemas/utils.py b/flexmeasures/api/common/schemas/utils.py index 11cdc6d220..536e062600 100644 --- a/flexmeasures/api/common/schemas/utils.py +++ b/flexmeasures/api/common/schemas/utils.py @@ -15,7 +15,7 @@ ) -def make_openapi_compatible(schema_cls: Type[Schema]) -> Type[Schema]: +def make_openapi_compatible(schema_cls: Type[Schema]) -> Type[Schema]: # noqa: C901 """ Create an OpenAPI-compatible version of a Marshmallow schema.