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
54 changes: 54 additions & 0 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
if not self.config_deserialized:
self.deserialize_config()

# todo: look for the reason why flex_model has an object(dict) without a sensor, and fix the root cause if possible, instead of filtering it out here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I know why this can happen. It happens when the db flex model of an asset is picked up when its parent asset is being scheduled. That means some devices are included in the optimization with only an asset ID known, and the power sensor ID to save the results is not guaranteed to be known.

Let's discuss what that means for this PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I actually want to keep support for device flex-models that don't have a power sensor defined. Such a device may have a complete flex-model description, but just lacking a place to store its scheduling results. And by now we also have the consumption and production fields that users can use to define where results need to be stored.

if isinstance(self.flex_model, list):
self.flex_model = [
model for model in self.flex_model if model["sensor"] is not None
]

start = self.start
end = self.end
resolution = self.resolution
Expand Down Expand Up @@ -983,6 +989,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
device_constraints,
ems_constraints,
commitments,
inflexible_device_sensors,
)

def convert_to_commitments(
Expand Down Expand Up @@ -1606,6 +1613,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
device_constraints,
ems_constraints,
commitments,
inflexible_device_sensors,
) = self._prepare(skip_validation=skip_validation)

# Fallback policy if the problem was unsolvable
Expand Down Expand Up @@ -1818,6 +1826,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
device_constraints,
ems_constraints,
commitments,
inflexible_device_sensors,
) = self._prepare(skip_validation=skip_validation)

ems_schedule, expected_costs, scheduler_results, model = device_scheduler(
Expand Down Expand Up @@ -1845,6 +1854,16 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
elif sensor is not None and sensor in storage_schedule:
storage_schedule[sensor] += ems_schedule[d]

# Obtain the inflexible device schedules
num_flexible_devices = len(sensors)
inflexible_schedules = dict()
for i, inflexible_sensor in enumerate(inflexible_device_sensors):
device_index = num_flexible_devices + i
if inflexible_sensor not in inflexible_schedules:
inflexible_schedules[inflexible_sensor] = ems_schedule[device_index]
else:
inflexible_schedules[inflexible_sensor] += ems_schedule[device_index]

# Obtain the aggregate power schedule, too, if the flex-context states the associated sensor. Fill with the sum of schedules made here.
aggregate_power_sensor = self.flex_context.get("aggregate_power", None)
if isinstance(aggregate_power_sensor, Sensor):
Expand All @@ -1864,6 +1883,18 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
if sensor is not None
}

# Convert each inflexible device schedule to the unit of the device's power sensor
inflexible_schedules = {
sensor: convert_units(
inflexible_schedules[sensor],
"MW",
sensor.unit,
event_resolution=sensor.event_resolution,
)
for sensor in inflexible_schedules.keys()
if sensor is not None
}

flex_model = self.flex_model.copy()

if not isinstance(self.flex_model, list):
Expand All @@ -1887,6 +1918,13 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
for sensor in storage_schedule.keys()
if sensor is not None
}
inflexible_schedules = {
sensor: inflexible_schedules[sensor]
.resample(sensor.event_resolution)
.mean()
for sensor in inflexible_schedules.keys()
if sensor is not None
}
consumption_production_schedule = {
sensor: consumption_production_schedule[sensor]
.resample(sensor.event_resolution)
Expand All @@ -1901,6 +1939,11 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
for sensor in storage_schedule.keys()
if sensor is not None
}
inflexible_schedules = {
sensor: inflexible_schedules[sensor].round(self.round_to_decimals)
for sensor in inflexible_schedules.keys()
if sensor is not None
}
soc_schedule = {
sensor: soc_schedule[sensor].round(self.round_to_decimals)
for sensor in soc_schedule.keys()
Expand All @@ -1923,6 +1966,16 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
for sensor in storage_schedule.keys()
if sensor is not None
]
inflexible_device_schedules = [
{
"name": "inflexible_device_schedule",
"sensor": sensor,
"data": inflexible_schedules[sensor],
"unit": sensor.unit,
}
for sensor in inflexible_schedules.keys()
if sensor is not None
]
Comment thread
Flix6x marked this conversation as resolved.
commitment_costs = [
{
"name": "commitment_costs",
Expand Down Expand Up @@ -1966,6 +2019,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType:
]
return (
storage_schedules
+ inflexible_device_schedules
+ commitment_costs
+ soc_schedules
+ consumption_production_schedules
Expand Down
3 changes: 3 additions & 0 deletions flexmeasures/data/models/planning/tests/test_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ def run_test_charge_discharge_sign(
device_constraints,
ems_constraints,
commitments,
inflexible_device_sensors,
) = scheduler._prepare(skip_validation=True)

planned_power_per_device, planned_costs, results, model = device_scheduler(
Expand Down Expand Up @@ -1382,6 +1383,7 @@ def test_numerical_errors(app_with_each_solver, setup_planning_test_data, db):
device_constraints,
ems_constraints,
commitments,
inflexible_device_sensors,
) = scheduler._prepare(skip_validation=True)

_, _, results, model = device_scheduler(
Expand Down Expand Up @@ -1564,6 +1566,7 @@ def set_if_not_none(dictionary, key, value):
device_constraints,
ems_constraints,
commitments,
inflexible_device_sensors,
) = scheduler._prepare(skip_validation=True)

assert all(device_constraints[0]["derivative min"] == -expected_capacity)
Expand Down
10 changes: 10 additions & 0 deletions flexmeasures/data/services/scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,16 @@ def make_schedule( # noqa: C901
if "sensor" not in result:
continue

# Skip inflexible-device schedules: the EMS fixes inflexible devices to the
# power values read from their sensors, so the resulting "schedule" merely
# mirrors the forecast/measurement already stored on those sensors. Writing
# it back here would only duplicate that input under the scheduler's data
# source without added value. These results are still returned by
# `Scheduler.compute(return_multiple=True)`, so callers that want to route
# them to dedicated output sensors can still do so.
if result.get("name") == "inflexible_device_schedule":
continue

# Ensure consumption_is_positive is set before resolving the sign.
# At job-creation time this is already done eagerly; calling it here again
# acts as a safety net for direct make_schedule invocations and raises a
Expand Down
Loading