diff --git a/documentation/changelog.rst b/documentation/changelog.rst index b3af30cec6..67e0d9ee1d 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -69,6 +69,7 @@ Infrastructure / Support Bugfixes ----------- +* Fix asset-level scheduling crashing with ``AttributeError: 'NoneType' object has no attribute 'generic_asset'`` when the flex-model list contains a single device entry without a top-level ``sensor`` key (e.g. a device referencing its power sensor only via a nested output reference, like ``{"consumption": {"sensor": ...}}``); such a flex-model now stays in multi-device mode, and a clear ``ValueError`` is raised if a device's power sensor genuinely cannot be resolved [see `PR #2361 `_] * Scheduling jobs no longer print ``Job ... made schedule.`` before ``scheduler.compute()`` runs (only after a successful schedule) [see `PR #2342 `_] * ``flexmeasures add user --roles`` now correctly accepts a comma-separated list of roles (and repeated ``--roles`` options) instead of creating one role whose name contains commas [see `PR #2339 `_] * Raise a clear ``ValueError`` when a flex-model references a missing sensor ID instead of ``AttributeError: 'NoneType' object has no attribute 'asset_id'`` [see `PR #2343 `_] diff --git a/flexmeasures/data/models/planning/__init__.py b/flexmeasures/data/models/planning/__init__.py index 0140b2421e..4383598a12 100644 --- a/flexmeasures/data/models/planning/__init__.py +++ b/flexmeasures/data/models/planning/__init__.py @@ -287,12 +287,22 @@ def collect_flex_config(self): {**v, "asset": k} for k, v in db_flex_model.items() if k not in asset_ids ] combined_flex_model = amended_db_flex_model + amended_flex_model - # For the single-asset case, revert the flex-model listification - if len(combined_flex_model) == 1 and "sensor" not in combined_flex_model[0]: - # Single-asset case + # For the single-sensor case, revert the flex-model listification. + # Only do so when scheduling a specific sensor: a bare dict flex-model is + # deserialized in single-sensor mode, which resolves the device against + # self.sensor. For asset-level scheduling (self.sensor is None), keep the + # list form (multi-device mode) even for a single device entry, as only + # multi-device mode can resolve a device's power sensor from nested + # references (e.g. {"consumption": {"sensor": ...}}). + if ( + self.sensor is not None + and len(combined_flex_model) == 1 + and "sensor" not in combined_flex_model[0] + ): + # Single-sensor case self.flex_model = combined_flex_model[0] else: - # Multi-asset case + # Multi-device case self.flex_model = combined_flex_model def deserialize_config(self): diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 734ceb506d..eb63b9e818 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1739,6 +1739,21 @@ def _deserialize_flex_context(self): def _deserialize_flex_model(self): if isinstance(self.flex_model, dict): + if self.sensor is None: + # Fail-safe: a bare dict flex-model is deserialized in + # single-sensor mode, which needs a power sensor to resolve the + # device against (asset-level scheduling should pass a list of + # device entries instead; see also collect_flex_config, which + # only unwraps a single-entry flex-model list when scheduling a + # specific sensor). + raise ValueError( + "Cannot resolve the power sensor of this flex-model entry: " + f"{self.flex_model}. When scheduling an asset, pass the " + "flex-model as a list of device entries, each referencing " + "its device through a 'sensor' key or a nested sensor " + "reference (such as under 'consumption', 'production' or " + "'state-of-charge')." + ) if self.sensor.generic_asset.asset_type.name in storage_asset_types: self.ensure_soc_at_start() diff --git a/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py b/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py index 840e8c9fd4..a5ef41c117 100644 --- a/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py +++ b/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py @@ -267,3 +267,95 @@ def test_collect_flex_config_missing_sensor_raises(fresh_db): ) with pytest.raises(ValueError, match=f"No sensor found with ID {missing_id}"): scheduler_soc.collect_flex_config() + + +def test_collect_flex_config_keeps_single_entry_list_for_asset(fresh_db): + """Asset-level scheduling keeps a single-entry flex-model list as a list. + + Regression test: ``collect_flex_config`` used to unwrap any single-entry + flex-model list without a top-level "sensor" key into a bare dict. For + asset-level scheduling there is no sensor to resolve a bare dict flex-model + against, so deserialization crashed with ``AttributeError: 'NoneType' + object has no attribute 'generic_asset'``. Only sensor-level scheduling + should unwrap (bare dict flex-models are resolved against that sensor). + """ + asset_type = GenericAssetType(name="test-asset-type-single-entry") + fresh_db.session.add(asset_type) + asset = GenericAsset(name="test-asset-single-entry", generic_asset_type=asset_type) + fresh_db.session.add(asset) + soc_sensor = Sensor( + name="test-soc-sensor-single-entry", + generic_asset=asset, + event_resolution=timedelta(0), + unit="MWh", + ) + fresh_db.session.add(soc_sensor) + fresh_db.session.commit() + + start = datetime(2023, 1, 1, tzinfo=ZoneInfo("UTC")) + end = start + timedelta(hours=1) + scheduler = StorageScheduler( + asset_or_sensor=asset, + start=start, + end=end, + resolution=timedelta(hours=1), + flex_model=[ + { + "state-of-charge": {"sensor": soc_sensor.id}, + "soc-at-start": "4 kWh", + } + ], + flex_context={}, + ) + scheduler.collect_flex_config() + assert isinstance(scheduler.flex_model, list) + assert len(scheduler.flex_model) == 1 + + # Sensor-level scheduling still unwraps to single-sensor (dict) mode + power_sensor = Sensor( + name="test-power-sensor-single-entry", + generic_asset=asset, + event_resolution=timedelta(hours=1), + unit="MW", + ) + fresh_db.session.add(power_sensor) + fresh_db.session.commit() + scheduler_sensor_level = StorageScheduler( + asset_or_sensor=power_sensor, + start=start, + end=end, + resolution=timedelta(hours=1), + flex_model={"soc-at-start": "4 kWh"}, + flex_context={}, + ) + scheduler_sensor_level.collect_flex_config() + assert isinstance(scheduler_sensor_level.flex_model, dict) + + +def test_deserialize_dict_flex_model_without_sensor_raises(fresh_db): + """Fail-safe: single-sensor (dict) flex-model deserialization without a sensor + raises a clear ValueError instead of ``AttributeError: 'NoneType' object has + no attribute 'generic_asset'``. + """ + asset_type = GenericAssetType(name="test-asset-type-dict-no-sensor") + fresh_db.session.add(asset_type) + asset = GenericAsset( + name="test-asset-dict-no-sensor", generic_asset_type=asset_type + ) + fresh_db.session.add(asset) + fresh_db.session.commit() + + start = datetime(2023, 1, 1, tzinfo=ZoneInfo("UTC")) + end = start + timedelta(hours=1) + scheduler = StorageScheduler( + asset_or_sensor=asset, + start=start, + end=end, + resolution=timedelta(hours=1), + flex_model={"soc-at-start": "4 kWh"}, + flex_context={}, + ) + # Force the (normally unreachable) dict flex-model deserialization path + scheduler.flex_model = {"soc-at-start": "4 kWh"} + with pytest.raises(ValueError, match="Cannot resolve the power sensor"): + scheduler._deserialize_flex_model() diff --git a/flexmeasures/data/tests/test_scheduling_jobs.py b/flexmeasures/data/tests/test_scheduling_jobs.py index f0ebbb6208..05ee1f9005 100644 --- a/flexmeasures/data/tests/test_scheduling_jobs.py +++ b/flexmeasures/data/tests/test_scheduling_jobs.py @@ -641,3 +641,80 @@ def test_scheduling_unit_conversion( assert ( min(v.event_value for v in power_values) == -max_allowed ), "Expected discharging (negative values at max rate" + + +def test_scheduling_single_device_with_nested_output_sensor_only( + fresh_db, + app, + smart_building, +): + """An asset-level scheduling job whose flex-model list holds a SINGLE device + entry without a top-level power sensor (the entry references its power + sensor only via a nested consumption output reference) must schedule and + save successfully. + + Regression test: ``collect_flex_config`` used to unwrap any single-entry + flex-model list without a top-level "sensor" key into a bare dict, which + made the StorageScheduler treat it as a sensor-level (single-sensor) + flex-model. For asset-level scheduling there is no sensor to resolve that + against, so the job crashed in ``_deserialize_flex_model`` with + ``AttributeError: 'NoneType' object has no attribute 'generic_asset'``. + The same device entry in a multi-entry list scheduled fine, because + multi-device (asset) mode resolves nested output sensor references. + """ + assets, sensors, soc_sensors = smart_building + queue = app.queues["scheduling"] + start = pd.Timestamp("2015-01-03").tz_localize("Europe/Amsterdam") + end = pd.Timestamp("2015-01-04").tz_localize("Europe/Amsterdam") + resolution = timedelta(minutes=15) + + # The device's only power sensor reference is this nested consumption output sensor + consumption_sensor = Sensor( + name="consumption output", + unit="MW", + event_resolution=resolution, + generic_asset=assets["Test Battery"], + timezone="Europe/Amsterdam", + ) + fresh_db.session.add(consumption_sensor) + fresh_db.session.flush() + + flex_model = [ + # A single entry without a top-level power sensor; its power sensor + # resolves to the nested consumption output sensor (see _resolve_power_sensor). + { + "consumption": {"sensor": consumption_sensor.id}, + "state-of-charge": {"sensor": soc_sensors["Test Battery"].id}, + "soc-at-start": "0.2 MWh", + "soc-min": "0 MWh", + "soc-max": "1 MWh", + "power-capacity": "1 MW", + }, + ] + flex_context = { + "consumption-price": "100 EUR/MWh", + "production-price": "50 EUR/MWh", + "site-power-capacity": "2 MW", + } + + # The job schedules successfully (this used to crash before scheduling) + job = create_scheduling_job( + asset_or_sensor=assets["Test Site"], + start=start, + end=end, + belief_time=start, + resolution=resolution, + flex_model=flex_model, + flex_context=flex_context, + enqueue=True, + ) + work_on_rq(queue, exc_handler=exception_reporter) + job.refresh() + assert job.get_status() == "finished", job.meta.get("exception") + + # The sensor got exactly one series of timed_belief rows (96 quarter-hours) + beliefs = fresh_db.session.scalars( + select(TimedBelief).filter(TimedBelief.sensor_id == consumption_sensor.id) + ).all() + assert len(beliefs) == 96 + assert len({belief.event_start for belief in beliefs}) == 96