Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
921d958
fix: enhance job ID return structure in TrainPredictPipeline
BelhsanHmida Mar 9, 2026
b7068fa
fix: update forecasting job return structure in SensorAPI
BelhsanHmida Mar 9, 2026
505c284
fix: update job fetching logic in test_train_predict_pipeline
BelhsanHmida Mar 9, 2026
10b370a
chore: remove commented-out breakpoint in test_forecasting.py
BelhsanHmida Mar 9, 2026
6342867
fix: as_job is no longer in parameters
BelhsanHmida Mar 9, 2026
cc1bf1b
fix: update job count retrieval in add_forecast function
BelhsanHmida Mar 9, 2026
a7ff4a9
fix: add connection queue to fetch job
BelhsanHmida Mar 9, 2026
745e513
style: black
Flix6x Mar 9, 2026
47f160d
docs: changelog entry
Flix6x Mar 9, 2026
3af2c62
feat: check if wrap-up job actually finished rather than failed
Flix6x Mar 9, 2026
474b860
feat: add test case for 2 cycles, yielding 2 jobs and a wrap-up job
Flix6x Mar 9, 2026
7f824a7
dev: comment out failing assert, which needs to be investgated and up…
Flix6x Mar 9, 2026
29705a2
refactor: move checking the status of the wrap-up job to where it mat…
Flix6x Mar 9, 2026
f26c41b
fix: use job ID itself in case the returned job is the one existing c…
Flix6x Mar 9, 2026
f326efc
fix: add db.commit before forecasting jobs are created
BelhsanHmida Mar 10, 2026
67862ed
dev: uncomment test assertion statement
BelhsanHmida Mar 10, 2026
64465e9
Test(feat): search all beliefs forecasts saved into the sensor by the…
BelhsanHmida Mar 10, 2026
4ac1846
test(feat): add n_cycles variable to use to account for length of for…
BelhsanHmida Mar 10, 2026
bdc5e28
style: run pre-commit
BelhsanHmida Mar 10, 2026
5340350
fix: improve assertion message in test_train_predict_pipeline for cla…
BelhsanHmida Mar 10, 2026
8271e28
Merge branch 'main' into fix/small-forecasting-pipeline-fixes
BelhsanHmida Mar 11, 2026
60a85b3
Merge branch 'main' into fix/small-forecasting-pipeline-fixes
BelhsanHmida Mar 11, 2026
4ebaa97
fix: first create all jobs, then queue all jobs, giving the db.sessio…
Flix6x Mar 17, 2026
888b980
feat: enqueue job only after the transactional request
Flix6x Mar 17, 2026
56f825a
Revert "feat: enqueue job only after the transactional request"
Flix6x Mar 17, 2026
e41c1a5
docs: resolve silent merge conflict in changelog
Flix6x Mar 17, 2026
9085b9e
Merge remote-tracking branch 'origin/main' into fix/small-forecasting…
Flix6x Mar 17, 2026
064f9cb
docs: delete duplicate changelog entry
Flix6x Mar 16, 2026
7ff82d1
docs: add release date for v0.31.2
Flix6x Mar 17, 2026
657b0c0
Merge remote-tracking branch 'origin/main' into fix/small-forecasting…
Flix6x Mar 17, 2026
ff78988
docs: advance a different bugfix to v0.31.2
Flix6x Mar 17, 2026
4ed641a
fix: self.data_source found itself in a different session somehow, so…
Flix6x Mar 17, 2026
d276c8a
Revert "fix: first create all jobs, then queue all jobs, giving the d…
Flix6x Mar 17, 2026
ddd1cf2
fix: reload forecasting pipeline orm state in worker session
BelhsanHmida Mar 18, 2026
91f0e88
fix: serialize train-predict cycle jobs for workers
BelhsanHmida Mar 18, 2026
243c011
revert: commit not related to this pr
BelhsanHmida Mar 18, 2026
38dd206
style: run pre-commit
BelhsanHmida Mar 18, 2026
1cf183b
Merge branch 'main' into fix/detached-forecasting-sensors
BelhsanHmida Mar 18, 2026
341751a
fix: fix test merge commit
BelhsanHmida Mar 19, 2026
e245936
fix: enqueue forecasting jobs with explicit payloads
BelhsanHmida May 29, 2026
6be62f4
test: assert forecasting job payload is session safe
BelhsanHmida May 29, 2026
e35336f
test: detect mapped job payload objects
BelhsanHmida May 29, 2026
9ab4db3
fix: avoid forwarding compute kwargs to forecast jobs
BelhsanHmida May 29, 2026
6640be1
style: format forecast job wrapper
BelhsanHmida May 29, 2026
723f1d9
merge: update forecasting fix with main
BelhsanHmida May 29, 2026
510a81d
docs: add changelog for forecast job fix
BelhsanHmida May 30, 2026
f2e3c9e
Merge branch 'main' into fix/detached-forecasting-sensors
BelhsanHmida Jun 1, 2026
11b1f65
Merge branch 'main' into fix/detached-forecasting-sensors
BelhsanHmida Jun 4, 2026
52b542c
Merge branch 'main' into fix/detached-forecasting-sensors
BelhsanHmida Jul 6, 2026
2a8c58e
Potential fix for pull request finding
BelhsanHmida Jul 6, 2026
8de4faf
fix: restore forecast job comment indentation
BelhsanHmida Jul 6, 2026
7f5369c
docs: move changelog entry to v1
BelhsanHmida Jul 7, 2026
2e02b5a
fix: remove unused forecast job kwargs
BelhsanHmida Jul 7, 2026
ca0a614
fix: preserve serialized forecast job payloads
BelhsanHmida Jul 8, 2026
b126866
test: cover serialized forecast job payloads
BelhsanHmida Jul 8, 2026
d9a2941
docs: clarify forecast job payload serialization
BelhsanHmida Jul 8, 2026
412cc6f
docs: document forecast job payload helpers
BelhsanHmida Jul 8, 2026
4b7a778
docs: remove duplicate changelog entry
BelhsanHmida Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Bugfixes
-----------
* Let storage scheduling treat missing constant SoC bounds as unconstrained lower or upper bounds [see `PR #2221 <https://www.github.com/FlexMeasures/flexmeasures/pull/2221>`_]
* Allow root assets belonging to different accounts to share the same name, while keeping asset names unique among root assets within the same account and among children of the same parent [see `PR #2226 <https://www.github.com/FlexMeasures/flexmeasures/pull/2226>`_]
* Fix queued train-predict forecasting jobs losing their resolved forecast window or failing on detached database objects in workers [see `PR #2035 <https://www.github.com/FlexMeasures/flexmeasures/pull/2035>`_]


v0.33.1 | July 1, 2026
Expand Down
6 changes: 3 additions & 3 deletions flexmeasures/data/models/forecasting/pipelines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import pandas as pd
from darts import TimeSeries
from darts.dataprocessing.transformers import MissingValuesFiller
from flexmeasures.data.models.time_series import Sensor
from timely_beliefs import utils as tb_utils

from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.models.forecasting.exceptions import NotEnoughDataException


Expand Down Expand Up @@ -90,11 +90,11 @@ def __init__(
self.target = f"{target_sensor.name} (ID: {target_sensor.id})_target"
self.future_regressors = [
f"{sensor.name} (ID: {sensor.id})_FR-{idx}"
for idx, sensor in enumerate(future_regressors)
for idx, sensor in enumerate(self.future)
]
self.past_regressors = [
f"{sensor.name} (ID: {sensor.id})_PR-{idx}"
for idx, sensor in enumerate(past_regressors)
for idx, sensor in enumerate(self.past)
]
self.predict_start = predict_start if predict_start else None
self.predict_end = predict_end if predict_end else None
Expand Down
214 changes: 158 additions & 56 deletions flexmeasures/data/models/forecasting/pipelines/train_predict.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,146 @@
from flask import current_app

from flexmeasures.data import db
from flexmeasures.data.models.data_sources import DataSource
from flexmeasures.data.models.forecasting import Forecaster
from flexmeasures.data.models.forecasting.pipelines.predict import PredictPipeline
from flexmeasures.data.models.forecasting.pipelines.train import TrainPipeline
from flexmeasures.data.models.time_series import Sensor
from flexmeasures.data.schemas.forecasting.pipeline import (
ForecasterParametersSchema,
TrainPredictPipelineConfigSchema,
)
from flexmeasures.utils.flexmeasures_inflection import p


def _sensor_id(sensor: Sensor | int | None) -> int | None:
"""Return the sensor ID from a Sensor object or already-serialized ID."""
if sensor is None:
return None
return sensor.id if isinstance(sensor, Sensor) else sensor


def _get_attached_sensor(sensor_id: int | None) -> Sensor | None:
"""Load a sensor in the current session from a queued job payload ID."""
if sensor_id is None:
return None
attached_sensor = db.session.get(Sensor, sensor_id)
if attached_sensor is None:
raise ValueError(f"Could not load sensor with id {sensor_id}.")
return attached_sensor


def _get_attached_data_source(data_source_id: int | None) -> DataSource | None:
"""Load a data source in the current session from a queued job payload ID."""
if data_source_id is None:
return None
attached_source = db.session.get(DataSource, data_source_id)
if attached_source is None:
raise ValueError(f"Could not load data source with id {data_source_id}.")
return attached_source


def _assert_no_orm_objects(value: Any, path: str = "payload") -> None:
"""Reject ORM objects before they can be pickled into an RQ job."""
inspection = sa_inspect(value, raiseerr=False)
if inspection is not None and hasattr(inspection, "object"):
raise ValueError(
f"Queued forecasting job {path} contains a "
f"{value.__class__.__name__} ORM object. Pass its ID instead."
)

if isinstance(value, dict):
for key, nested_value in value.items():
_assert_no_orm_objects(nested_value, f"{path}.{key}")
elif isinstance(value, (list, tuple, set)):
for index, nested_value in enumerate(value):
_assert_no_orm_objects(nested_value, f"{path}[{index}]")


def _make_job_config_payload(config: dict[str, Any]) -> dict[str, Any]:
"""Build the queued worker config payload.

ORM-backed fields are replaced by IDs, while plain config fields are preserved.
"""
# Preserve plain config fields, but replace ORM-backed regressors by IDs.
payload = dict(config)
future_regressors = payload.pop("future_regressors", [])
past_regressors = payload.pop("past_regressors", [])
payload["future_regressor_ids"] = [
_sensor_id(sensor) for sensor in future_regressors
]
payload["past_regressor_ids"] = [_sensor_id(sensor) for sensor in past_regressors]
_assert_no_orm_objects(payload)
return payload


def _load_job_config_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Restore worker config and reload regressors in the worker session."""
config = dict(payload)
config["future_regressors"] = [
_get_attached_sensor(sensor_id)
for sensor_id in config.pop("future_regressor_ids", [])
]
config["past_regressors"] = [
_get_attached_sensor(sensor_id)
for sensor_id in config.pop("past_regressor_ids", [])
]
return config


def _make_job_parameters_payload(parameters: dict[str, Any]) -> dict[str, Any]:
"""Build the queued worker parameter payload.

ORM-backed fields are replaced by IDs, while plain parameter fields are preserved.
"""
# Preserve plain parameters, but replace ORM-backed sensors by IDs.
payload = dict(parameters)
sensor_id = _sensor_id(payload.pop("sensor"))
sensor_to_save_id = _sensor_id(payload.pop("sensor_to_save", None))
if sensor_id is None:
raise ValueError("Cannot enqueue a forecasting job without a target sensor.")
payload["sensor_id"] = sensor_id
payload["sensor_to_save_id"] = sensor_to_save_id or sensor_id
_assert_no_orm_objects(payload)
return payload


def _load_job_parameters_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Restore worker parameters and reload sensors in the worker session."""
parameters = dict(payload)
parameters["sensor"] = _get_attached_sensor(parameters.pop("sensor_id"))
parameters["sensor_to_save"] = _get_attached_sensor(
parameters.pop("sensor_to_save_id")
)
return parameters


def run_train_predict_cycle_job(
config: dict,
parameters: dict,
data_source_id: int,
delete_model: bool,
**cycle_params,
):
"""Run one train-predict cycle after reconstructing worker-local ORM state."""
pipeline = TrainPredictPipeline(delete_model=delete_model)
pipeline._config = _load_job_config_payload(config)
for key, value in pipeline._config.items():
setattr(pipeline, key, value)
pipeline._parameters = _load_job_parameters_payload(parameters)
pipeline._data_source = _get_attached_data_source(data_source_id)
return pipeline.run_cycle(**cycle_params)


def run_train_predict_wrap_up_job(cycle_job_ids: list[str], queue: str = "forecasting"):
"""Log the status of all cycle jobs after completion."""
connection = current_app.queues[queue].connection

for index, job_id in enumerate(cycle_job_ids):
status = Job.fetch(job_id, connection=connection).get_status()
logging.info(f"{queue} job-{index}: {job_id} status: {status}")


class TrainPredictPipeline(Forecaster):

__version__ = "1"
Expand All @@ -46,28 +176,9 @@ def __init__(
self.delete_model = delete_model
self.return_values = [] # To store forecasts and jobs

@staticmethod
def _reattach_if_needed(obj):
"""Re-merge a SQLAlchemy object into the current session if it is detached or expired.

After ``db.session.commit()``, all objects in the session are expired.
When RQ pickles ``self.run_cycle`` for a worker, expired or detached
objects may raise ``DetachedInstanceError`` on attribute access. This
helper merges such objects back into the active session so they are
usable when the worker executes the job.
"""
insp = sa_inspect(obj)
if insp.detached or insp.expired:
return db.session.merge(obj)
return obj

def run_wrap_up(self, cycle_job_ids: list[str], queue: str = "forecasting"):
"""Log the status of all cycle jobs after completion."""
connection = current_app.queues[queue].connection

for index, job_id in enumerate(cycle_job_ids):
status = Job.fetch(job_id, connection=connection).get_status()
logging.info(f"{queue} job-{index}: {job_id} status: {status}")
run_train_predict_wrap_up_job(cycle_job_ids, queue)

def run_cycle(
self,
Expand All @@ -86,25 +197,6 @@ def run_cycle(
f"Starting Train-Predict cycle from {train_start} to {predict_end}"
)

# Re-attach sensor objects if they are detached after RQ pickles/unpickles self
# (this can happen when a commit expires objects before RQ serializes the job).
self._parameters["sensor"] = self._reattach_if_needed(
self._parameters["sensor"]
)
sensor_to_save = self._parameters.get("sensor_to_save")
if sensor_to_save is not None:
self._parameters["sensor_to_save"] = self._reattach_if_needed(
sensor_to_save
)
# Also re-attach regressor sensors stored in _config
self._config["future_regressors"] = [
self._reattach_if_needed(s)
for s in self._config.get("future_regressors", [])
]
self._config["past_regressors"] = [
self._reattach_if_needed(s) for s in self._config.get("past_regressors", [])
]

# Train model
train_pipeline = TrainPipeline(
future_regressors=self._config["future_regressors"],
Expand Down Expand Up @@ -187,8 +279,8 @@ def run_cycle(
return total_runtime

def _compute_forecast(self, as_job: bool = False, **kwargs) -> list[dict[str, Any]]:
# Run the train-and-predict pipeline
return self.run(as_job=as_job, **kwargs)
# DataGenerator.compute already loaded kwargs into self._parameters.
return self.run(as_job=as_job)

def _derive_training_period(self) -> tuple[datetime, datetime]:
"""Derive the effective training period for model fitting.
Expand Down Expand Up @@ -234,7 +326,6 @@ 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."
Expand Down Expand Up @@ -285,7 +376,6 @@ def run(
cycle_runtime = self.run_cycle(**train_predict_params)
cumulative_cycles_runtime += cycle_runtime
else:
train_predict_params["target_sensor_id"] = self._parameters["sensor"].id
cycles_job_params.append(train_predict_params)

train_end += cycle_frequency
Expand All @@ -299,27 +389,39 @@ def run(
if as_job:
cycle_job_ids = []

# Ensure the data source is attached to the current session before
# committing. get_or_create_source() only flushes (does not commit), so
# without this merge the data source would not be found by the worker.
db.session.merge(self.data_source)
job_config = _make_job_config_payload(self._config)
job_parameters = _make_job_parameters_payload(self._parameters)
sensor_id = job_parameters["sensor_id"]
sensor_to_save_id = job_parameters["sensor_to_save_id"]

# Ensure the data source ID is available in the database when the job runs.
self._data_source = db.session.merge(self.data_source)
db.session.flush()
data_source_id = self._data_source.id
db.session.commit()

# job metadata for tracking
# Serialize start and end to ISO format strings
# Workaround for https://github.com/Parallels/rq-dashboard/issues/510
job_metadata = {
"data_source_info": {"id": self.data_source.id},
"data_source_info": {"id": data_source_id},
"start": self._parameters["predict_start"].isoformat(),
"end": self._parameters["end_date"].isoformat(),
"sensor_id": self._parameters["sensor_to_save"].id,
"sensor_id": sensor_to_save_id,
}
for cycle_params in cycles_job_params:
job_kwargs = {
"config": job_config,
"parameters": job_parameters,
"data_source_id": data_source_id,
"delete_model": self.delete_model,
**cycle_params,
}
_assert_no_orm_objects(job_kwargs)

job = Job.create(
self.run_cycle,
# Some cycle job params override job kwargs
kwargs={**job_kwargs, **cycle_params},
run_train_predict_cycle_job,
kwargs=job_kwargs,
connection=connection,
ttl=int(
current_app.config.get(
Expand All @@ -340,18 +442,18 @@ def run(

current_app.queues[queue].enqueue_job(job)
current_app.job_cache.add(
self._parameters["sensor"].id,
sensor_id,
job_id=job.id,
queue=queue,
asset_or_sensor_type="sensor",
)

wrap_up_job = Job.create(
self.run_wrap_up,
run_train_predict_wrap_up_job,
kwargs={
"cycle_job_ids": cycle_job_ids, # cycles jobs IDs to wait for
"cycle_job_ids": cycle_job_ids,
"queue": queue,
},
}, # cycles jobs IDs to wait for
connection=connection,
depends_on=cycle_job_ids, # wrap-up job depends on all cycle jobs
ttl=int(
Expand Down
Loading
Loading