Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2361>`_]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this bug is unreleased. Append the PR reference to the changelog entry for the new consumption field instead.

* Scheduling jobs no longer print ``Job ... made schedule.`` before ``scheduler.compute()`` runs (only after a successful schedule) [see `PR #2342 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2343>`_]
Expand Down
18 changes: 14 additions & 4 deletions flexmeasures/data/models/planning/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
15 changes: 15 additions & 0 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
92 changes: 92 additions & 0 deletions flexmeasures/data/models/planning/tests/test_utils_fresh_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
77 changes: 77 additions & 0 deletions flexmeasures/data/tests/test_scheduling_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading