diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 4c59a0ffa9..2b168cbb7e 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -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 + 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 @@ -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( @@ -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 @@ -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( @@ -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): @@ -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): @@ -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) @@ -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() @@ -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 + ] commitment_costs = [ { "name": "commitment_costs", @@ -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 diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index e9e61da848..469038ed4d 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -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( @@ -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( @@ -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) diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 5c7c7886fd..4c629621a3 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -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