From 12e19cfdf07060312b120fee5d2c56331bae11e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:42:09 +0000 Subject: [PATCH 01/11] Initial plan From ad3a956a09a6e3dd6762eb6a4a30e272a5e71f08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:08:45 +0000 Subject: [PATCH 02/11] feat: compute first unmet soc-minima/soc-maxima targets in storage scheduler - Add SchedulingJobResult dataclass (JSON-serializable) to store job results - Modify _build_soc_schedule to also return per-device MWh SoC schedules, including for devices with soc-minima/soc-maxima constraints but no SoC sensor - Add _compute_unresolved_targets to find the first violated soc-minima/soc-maxima - StorageScheduler.compute() now includes scheduling_result in return_multiple output - make_schedule() stores SchedulingJobResult in rq_job.meta["scheduling_result"] - get_schedule API endpoint returns scheduling_result next to scheduler_info - Document that soc-targets are hard constraints (not reported in unresolved_targets) - Add tests: test_unresolved_targets_soc_minima and test_unresolved_targets_none_when_met - Add changelog entry for PR #2072 Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/710e6bc9-87d9-4238-9c3f-c79a445aff3e Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- documentation/changelog.rst | 1 + flexmeasures/api/v3_0/sensors.py | 28 ++- flexmeasures/data/models/planning/storage.py | 197 ++++++++++++++---- .../models/planning/tests/test_storage.py | 127 +++++++++++ flexmeasures/data/services/scheduling.py | 4 + .../data/services/scheduling_result.py | 26 +++ 6 files changed, 345 insertions(+), 38 deletions(-) create mode 100644 flexmeasures/data/services/scheduling_result.py diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 64aa928be7..5a095c70fa 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -14,6 +14,7 @@ New features * Version headers (for server and API) in API responses [see `PR #2021 `_] * Show sensor attributes on sensor page, if not empty [see `PR #2015 `_] * Separate the ``StorageScheduler``'s tie-breaking preference for a full :abbr:`SoC (state of charge)` from its reported energy costs [see `PR #2023 `_] +* The schedule API endpoint now returns a ``scheduling_result`` field alongside ``scheduler_info``. For the first unmet ``soc-minima`` or ``soc-maxima`` constraint it reports the violation datetime and the signed delta (scheduled SoC minus target value). Note that ``soc-targets`` are hard constraints and are never reported here [see `PR #2072 `_] Infrastructure / Support ---------------------- diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 4fc4ac1ae1..6b2939803c 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -931,6 +931,23 @@ def get_schedule( # noqa: C901 description: Information about the scheduler that executed the job. additionalProperties: true + scheduling_result: + type: object + description: | + Additional results produced by the scheduler. + + The ``unresolved_targets`` field reports the first time at which the + scheduled state of charge (SoC) violates a soft SoC constraint, along + with the signed difference (scheduled SoC minus target value). + A negative ``delta`` for ``soc-minima`` means the SoC is below the + minimum; a positive ``delta`` for ``soc-maxima`` means the SoC exceeds + the maximum. + + Note: ``soc-targets`` are modelled as hard constraints, so the + scheduler will never allow a deviation from them by definition. + They are therefore not reported here. + additionalProperties: true + values: type: array items: @@ -1086,8 +1103,17 @@ def get_schedule( # noqa: C901 unit=unit, ) + scheduling_result = job.meta.get("scheduling_result", {}) d, s = request_processed(scheduler_info_msg) - return dict(scheduler_info=scheduler_info, **response, **d), s + return ( + dict( + scheduler_info=scheduler_info, + scheduling_result=scheduling_result, + **response, + **d, + ), + s, + ) @route("/", methods=["GET"]) @use_kwargs({"sensor": SensorIdField(data_key="id")}, location="path") diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 2a37cd600e..8a4e1d69f0 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1303,9 +1303,13 @@ def _build_soc_schedule( soc_at_start: list[float], device_constraints: list, resolution: timedelta, - ) -> dict: + ) -> tuple[dict, dict]: """Build the state-of-charge schedule for each device that has a state-of-charge sensor. + Also computes the MWh SoC for devices that have ``soc-minima`` or ``soc-maxima`` + constraints (even without a state-of-charge sensor) so that unresolved targets can be + checked later. + Converts the integrated power schedule from MWh to the sensor's unit. For sensors with a '%' unit, the soc-max flex-model field is used as capacity. If soc-max is missing or zero for a '%' sensor, the schedule is skipped with a warning. @@ -1313,46 +1317,150 @@ def _build_soc_schedule( Note: soc-max is a QuantityField (not a VariableQuantityField), so it is always a float after deserialization and cannot be a sensor reference. The isinstance guard below is therefore a defensive check for forward-compatibility. + + :returns: Tuple of (soc_schedule keyed by SoC sensor in sensor unit, + soc_schedule_mwh keyed by device index in MWh). """ soc_schedule = {} + soc_schedule_mwh = {} for d, flex_model_d in enumerate(flex_model): state_of_charge_sensor = flex_model_d.get("state_of_charge", None) - if not isinstance(state_of_charge_sensor, Sensor): + has_soc_sensor = isinstance(state_of_charge_sensor, Sensor) + has_soc_constraints = ( + flex_model_d.get("soc_minima") is not None + or flex_model_d.get("soc_maxima") is not None + ) + # Skip devices that neither have a SoC sensor nor SoC constraints + if not has_soc_sensor and not has_soc_constraints: continue - soc_unit = state_of_charge_sensor.unit - capacity = None - if soc_unit == "%": - soc_max = flex_model_d.get("soc_max") - if isinstance(soc_max, Sensor): - raise ValueError( - f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: " - "soc-max as a sensor reference is not supported for '%' unit conversion. " - "Skipping state-of-charge schedule." - ) - if not soc_max: - raise ValueError( - f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: " - "soc-max is missing or zero. Skipping state-of-charge schedule." - ) - capacity = f"{soc_max} MWh" # all flex model fields are in MWh by now - soc_schedule[state_of_charge_sensor] = convert_units( - integrate_time_series( - series=ems_schedule[d], - initial_stock=soc_at_start[d], - stock_delta=device_constraints[d]["stock delta"] - * resolution - / timedelta(hours=1), - up_efficiency=device_constraints[d]["derivative up efficiency"], - down_efficiency=device_constraints[d]["derivative down efficiency"], - storage_efficiency=device_constraints[d]["efficiency"] - .astype(float) - .fillna(1), - ), - from_unit="MWh", - to_unit=soc_unit, - capacity=capacity, + # Skip devices without a known initial SoC (required for integration) + if soc_at_start[d] is None: + continue + + soc_mwh = integrate_time_series( + series=ems_schedule[d], + initial_stock=soc_at_start[d], + stock_delta=device_constraints[d]["stock delta"] + * resolution + / timedelta(hours=1), + up_efficiency=device_constraints[d]["derivative up efficiency"], + down_efficiency=device_constraints[d]["derivative down efficiency"], + storage_efficiency=device_constraints[d]["efficiency"] + .astype(float) + .fillna(1), ) - return soc_schedule + soc_schedule_mwh[d] = soc_mwh + + if has_soc_sensor: + soc_unit = state_of_charge_sensor.unit + capacity = None + if soc_unit == "%": + soc_max = flex_model_d.get("soc_max") + if isinstance(soc_max, Sensor): + raise ValueError( + f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: " + "soc-max as a sensor reference is not supported for '%' unit conversion. " + "Skipping state-of-charge schedule." + ) + if not soc_max: + raise ValueError( + f"Cannot convert state-of-charge schedule to '%' unit for sensor {state_of_charge_sensor.id}: " + "soc-max is missing or zero. Skipping state-of-charge schedule." + ) + capacity = ( + f"{soc_max} MWh" # all flex model fields are in MWh by now + ) + soc_schedule[state_of_charge_sensor] = convert_units( + soc_mwh, + from_unit="MWh", + to_unit=soc_unit, + capacity=capacity, + ) + return soc_schedule, soc_schedule_mwh + + def _compute_unresolved_targets( + self, + flex_model: list[dict], + soc_schedule_mwh: dict, + start: datetime, + end: datetime, + resolution: timedelta, + ) -> dict: + """Compute the first unmet SoC minima and maxima targets across all scheduled devices. + + For each device that has ``soc-minima`` or ``soc-maxima`` constraints in the flex model, + compares the computed MWh SoC schedule against those constraints. The first (earliest + in time) violation of each type is reported. + + Note: ``soc-targets`` are modelled as hard constraints and are not checked here, + as by definition the scheduler will not allow any deviation from them. + + :param flex_model: The deserialized flex model (list of per-device dicts). + :param soc_schedule_mwh: MWh SoC schedule keyed by device index ``d``. + :param start: Start of the schedule. + :param end: End of the schedule. + :param resolution: Schedule resolution. + :returns: dict with keys ``"soc-minima"`` and/or ``"soc-maxima"``, each containing + ``{"datetime": , "delta": }`` for the first + unmet target. ``delta`` equals scheduled SoC minus target value (negative + means the SoC is below the minimum; positive means it exceeds the maximum). + """ + result: dict = {} + + for d, flex_model_d in enumerate(flex_model): + soc_mwh = soc_schedule_mwh.get(d) + if soc_mwh is None: + continue + + # Check soc_minima (first time slot where scheduled SoC < minima) + soc_minima_d = flex_model_d.get("soc_minima") + if soc_minima_d is not None and "soc-minima" not in result: + soc_minima_series = get_continuous_series_sensor_or_quantity( + variable_quantity=soc_minima_d, + unit="MWh", + query_window=(start + resolution, end + resolution), + resolution=resolution, + beliefs_before=self.belief_time, + as_instantaneous_events=True, + resolve_overlaps="max", + ) + defined_minima = soc_minima_series.dropna() + if len(defined_minima) > 0: + aligned_soc = soc_mwh.reindex(defined_minima.index) + deltas = aligned_soc - defined_minima + violations = deltas[deltas < 0] + if not violations.empty: + first_t = violations.index[0] + result["soc-minima"] = { + "datetime": first_t.isoformat(), + "delta": round(float(deltas[first_t]), 6), + } + + # Check soc_maxima (first time slot where scheduled SoC > maxima) + soc_maxima_d = flex_model_d.get("soc_maxima") + if soc_maxima_d is not None and "soc-maxima" not in result: + soc_maxima_series = get_continuous_series_sensor_or_quantity( + variable_quantity=soc_maxima_d, + unit="MWh", + query_window=(start + resolution, end + resolution), + resolution=resolution, + beliefs_before=self.belief_time, + as_instantaneous_events=True, + resolve_overlaps="min", + ) + defined_maxima = soc_maxima_series.dropna() + if len(defined_maxima) > 0: + aligned_soc = soc_mwh.reindex(defined_maxima.index) + deltas = aligned_soc - defined_maxima + violations = deltas[deltas > 0] + if not violations.empty: + first_t = violations.index[0] + result["soc-maxima"] = { + "datetime": first_t.isoformat(), + "delta": round(float(deltas[first_t]), 6), + } + + return result def compute(self, skip_validation: bool = False) -> SchedulerOutputType: """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. @@ -1423,7 +1531,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: flex_model["sensor"] = sensors[0] flex_model = [flex_model] - soc_schedule = self._build_soc_schedule( + soc_schedule, soc_schedule_mwh = self._build_soc_schedule( flex_model, ems_schedule, soc_at_start, device_constraints, resolution ) @@ -1450,6 +1558,13 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: } if self.return_multiple: + from flexmeasures.data.services.scheduling_result import ( + SchedulingJobResult, + ) + + unresolved_targets = self._compute_unresolved_targets( + flex_model, soc_schedule_mwh, start, end, resolution + ) storage_schedules = [ { "name": "storage_schedule", @@ -1481,7 +1596,15 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: } for sensor, soc in soc_schedule.items() ] - return storage_schedules + commitment_costs + soc_schedules + scheduling_result = [ + { + "name": "scheduling_result", + "data": SchedulingJobResult(unresolved_targets=unresolved_targets), + } + ] + return ( + storage_schedules + commitment_costs + soc_schedules + scheduling_result + ) else: return storage_schedule[sensors[0]] diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index fae18f8715..2e3946393a 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -13,6 +13,7 @@ get_sensors_from_db, series_to_ts_specs, ) +from flexmeasures.data.services.scheduling_result import SchedulingJobResult def test_battery_solver_multi_commitment(add_battery_assets, db): @@ -248,3 +249,129 @@ def test_battery_relaxation(add_battery_assets, db): costs["all consumption breaches device 0"], device_power_breach_price * consumption_capacity_in_mw * 1000 * 4, ) # 100 EUR/(kW*h) * 0.025 MW * 1000 kW/MW * 4 hours + + +def test_unresolved_targets_soc_minima(add_battery_assets, db): + """Test that unresolved soc-minima targets are reported in the scheduling result. + + A battery starts at 0.4 MWh with a very limited charging capacity (0.01 MW), + so it can only gain 0.01 * 24 = 0.24 MWh over 24 hours => max SoC ~0.64 MWh. + A soc-minima of 0.9 MWh is set as a soft constraint (via a breach price). + The scheduler will charge at full capacity but still fail to reach the target, + so the scheduling result should report an unresolved soc-minima. + """ + _, battery = get_sensors_from_db( + db, add_battery_assets, battery_name="Test battery" + ) + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 1)) + end = tz.localize(datetime(2015, 1, 2)) + resolution = timedelta(minutes=15) + soc_at_start = 0.4 + index = initialize_index(start=start, end=end, resolution=resolution) + consumption_prices = pd.Series(100, index=index) + + scheduler: Scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model={ + "soc-at-start": f"{soc_at_start} MWh", + "soc-min": "0 MWh", + "soc-max": "1 MWh", + "power-capacity": "0.01 MVA", # very limited: max gain 0.24 MWh over 24 h + "soc-minima": [ + { + "datetime": "2015-01-02T00:00:00+01:00", + "value": "0.9 MWh", # unreachable + } + ], + "prefer-charging-sooner": False, + }, + flex_context={ + "consumption-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"), + "production-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"), + "site-power-capacity": "2 MW", + "soc-minima-breach-price": "1 EUR/kWh", # soft constraint + }, + return_multiple=True, + ) + results = scheduler.compute() + + # The scheduling_result entry should be present + scheduling_result_entry = next( + (r for r in results if r.get("name") == "scheduling_result"), None + ) + assert scheduling_result_entry is not None + + scheduling_result = scheduling_result_entry["data"] + assert isinstance(scheduling_result, SchedulingJobResult) + + unresolved_targets = scheduling_result.unresolved_targets + assert ( + "soc-minima" in unresolved_targets + ), "Expected an unresolved soc-minima since the target is unreachable" + # The scheduled SoC should be below the 0.9 MWh target (delta is negative) + assert unresolved_targets["soc-minima"]["delta"] < 0 + # Confirm the datetime is the end of the schedule + assert unresolved_targets["soc-minima"]["datetime"].startswith("2015-01-01T") + + # No soc-maxima was set, so it should not appear + assert "soc-maxima" not in unresolved_targets + + +def test_unresolved_targets_none_when_met(add_battery_assets, db): + """Test that no unresolved targets are reported when constraints are fully met. + + A battery starts at 0.4 MWh and has a soc-minima of 0.5 MWh at end of schedule. + With enough capacity, the scheduler can easily charge to 0.5 MWh, so the + scheduling result should have no unresolved soc-minima. + """ + _, battery = get_sensors_from_db( + db, add_battery_assets, battery_name="Test battery" + ) + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 1)) + end = tz.localize(datetime(2015, 1, 2)) + resolution = timedelta(minutes=15) + soc_at_start = 0.4 + index = initialize_index(start=start, end=end, resolution=resolution) + consumption_prices = pd.Series(100, index=index) + + scheduler: Scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model={ + "soc-at-start": f"{soc_at_start} MWh", + "soc-min": "0 MWh", + "soc-max": "1 MWh", + "power-capacity": "2 MVA", # plenty of capacity to reach 0.5 MWh + "soc-minima": [ + { + "datetime": "2015-01-02T00:00:00+01:00", + "value": "0.5 MWh", # easily reachable + } + ], + "prefer-charging-sooner": False, + }, + flex_context={ + "consumption-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"), + "production-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"), + "site-power-capacity": "2 MW", + "soc-minima-breach-price": "1 EUR/kWh", # soft constraint + }, + return_multiple=True, + ) + results = scheduler.compute() + + scheduling_result_entry = next( + (r for r in results if r.get("name") == "scheduling_result"), None + ) + assert scheduling_result_entry is not None + unresolved_targets = scheduling_result_entry["data"].unresolved_targets + # The minima target is met, so no unresolved targets expected + assert "soc-minima" not in unresolved_targets + assert "soc-maxima" not in unresolved_targets diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index 50502d04fa..c719a3b1d0 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -632,6 +632,10 @@ def make_schedule( # noqa: C901 # Save any result that specifies a sensor to save it to for result in consumption_schedule: + if result.get("name") == "scheduling_result" and rq_job: + rq_job.meta["scheduling_result"] = result["data"].to_dict() + rq_job.save_meta() + continue if "sensor" not in result: continue diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py new file mode 100644 index 0000000000..8bf7cad739 --- /dev/null +++ b/flexmeasures/data/services/scheduling_result.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass +class SchedulingJobResult: + """Results from a scheduling job, to be stored in the job's metadata. + + JSON serializable to enable storage in RQ job metadata and retrieval via the API. + + Note: ``soc-targets`` are modelled as hard constraints in the scheduler, meaning + the scheduler will not allow any deviation from them by definition. Therefore, + unmet ``soc-targets`` are not reported here. + """ + + unresolved_targets: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + """Serialize to a JSON-compatible dict.""" + return {"unresolved_targets": self.unresolved_targets} + + @classmethod + def from_dict(cls, d: dict) -> "SchedulingJobResult": + """Deserialize from a dict.""" + return cls(unresolved_targets=d.get("unresolved_targets", {})) From 9e12432752494c4fbd00b4109d2e733313d22e22 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:18:19 +0000 Subject: [PATCH 03/11] refactor: address code review comments on unresolved targets feature - Add docstring to SchedulingJobResult.unresolved_targets documenting the dict structure - Rename has_soc_constraints to has_soc_minima_maxima for clarity - Move SchedulingJobResult import to module level in storage.py - Use self.round_to_decimals for delta precision (defaults to 6) - Return None (not {}) from get_schedule when scheduling_result not in job meta - Mark scheduling_result as nullable in OpenAPI schema - Improve test docstring to clarify efficiency assumptions Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/710e6bc9-87d9-4238-9c3f-c79a445aff3e Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- flexmeasures/api/v3_0/sensors.py | 8 +++++-- flexmeasures/data/models/planning/storage.py | 24 +++++++++++-------- .../models/planning/tests/test_storage.py | 10 ++++---- .../data/services/scheduling_result.py | 18 ++++++++++++++ 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 6b2939803c..29dd54fd78 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -932,9 +932,11 @@ def get_schedule( # noqa: C901 additionalProperties: true scheduling_result: + nullable: true type: object description: | - Additional results produced by the scheduler. + Additional results produced by the scheduler, or ``null`` for jobs + created before this field was introduced. The ``unresolved_targets`` field reports the first time at which the scheduled state of charge (SoC) violates a soft SoC constraint, along @@ -1103,7 +1105,9 @@ def get_schedule( # noqa: C901 unit=unit, ) - scheduling_result = job.meta.get("scheduling_result", {}) + # Returns None if the job predates the scheduling_result feature (no meta key), + # or the dict with unresolved_targets if computed. + scheduling_result = job.meta.get("scheduling_result") d, s = request_processed(scheduler_info_msg) return ( dict( diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 8a4e1d69f0..47e775d918 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -34,6 +34,7 @@ FlexContextSchema, MultiSensorFlexModelSchema, ) +from flexmeasures.data.services.scheduling_result import SchedulingJobResult from flexmeasures.utils.calculations import ( integrate_time_series, ) @@ -1326,12 +1327,12 @@ def _build_soc_schedule( for d, flex_model_d in enumerate(flex_model): state_of_charge_sensor = flex_model_d.get("state_of_charge", None) has_soc_sensor = isinstance(state_of_charge_sensor, Sensor) - has_soc_constraints = ( + has_soc_minima_maxima = ( flex_model_d.get("soc_minima") is not None or flex_model_d.get("soc_maxima") is not None ) - # Skip devices that neither have a SoC sensor nor SoC constraints - if not has_soc_sensor and not has_soc_constraints: + # Skip devices that neither have a SoC sensor nor soc-minima/soc-maxima constraints + if not has_soc_sensor and not has_soc_minima_maxima: continue # Skip devices without a known initial SoC (required for integration) if soc_at_start[d] is None: @@ -1390,7 +1391,13 @@ def _compute_unresolved_targets( For each device that has ``soc-minima`` or ``soc-maxima`` constraints in the flex model, compares the computed MWh SoC schedule against those constraints. The first (earliest - in time) violation of each type is reported. + in time) violation of each type is reported, across all devices. Once the first + violation of a given type is found for any device, subsequent devices are skipped for + that type. + + Constraints are evaluated over the window ``(start + resolution, end)`` (i.e. the + first scheduled slot through the end of the schedule). The ``start`` slot itself is + the initial condition (``soc_at_start``), not a scheduled value, so it is excluded. Note: ``soc-targets`` are modelled as hard constraints and are not checked here, as by definition the scheduler will not allow any deviation from them. @@ -1406,6 +1413,7 @@ def _compute_unresolved_targets( means the SoC is below the minimum; positive means it exceeds the maximum). """ result: dict = {} + precision = self.round_to_decimals if self.round_to_decimals is not None else 6 for d, flex_model_d in enumerate(flex_model): soc_mwh = soc_schedule_mwh.get(d) @@ -1433,7 +1441,7 @@ def _compute_unresolved_targets( first_t = violations.index[0] result["soc-minima"] = { "datetime": first_t.isoformat(), - "delta": round(float(deltas[first_t]), 6), + "delta": round(float(deltas[first_t]), precision), } # Check soc_maxima (first time slot where scheduled SoC > maxima) @@ -1457,7 +1465,7 @@ def _compute_unresolved_targets( first_t = violations.index[0] result["soc-maxima"] = { "datetime": first_t.isoformat(), - "delta": round(float(deltas[first_t]), 6), + "delta": round(float(deltas[first_t]), precision), } return result @@ -1558,10 +1566,6 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: } if self.return_multiple: - from flexmeasures.data.services.scheduling_result import ( - SchedulingJobResult, - ) - unresolved_targets = self._compute_unresolved_targets( flex_model, soc_schedule_mwh, start, end, resolution ) diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 2e3946393a..4cfc797afe 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -254,8 +254,10 @@ def test_battery_relaxation(add_battery_assets, db): def test_unresolved_targets_soc_minima(add_battery_assets, db): """Test that unresolved soc-minima targets are reported in the scheduling result. - A battery starts at 0.4 MWh with a very limited charging capacity (0.01 MW), - so it can only gain 0.01 * 24 = 0.24 MWh over 24 hours => max SoC ~0.64 MWh. + A battery starts at 0.4 MWh with a very limited charging capacity (0.01 MW). + With 100% efficiency and 24 hours, it can gain at most 0.01 * 24 = 0.24 MWh, + reaching a max SoC of ~0.64 MWh. No roundtrip or storage efficiency is set, + so the default (100%) applies. A soc-minima of 0.9 MWh is set as a soft constraint (via a breach price). The scheduler will charge at full capacity but still fail to reach the target, so the scheduling result should report an unresolved soc-minima. @@ -314,8 +316,8 @@ def test_unresolved_targets_soc_minima(add_battery_assets, db): ), "Expected an unresolved soc-minima since the target is unreachable" # The scheduled SoC should be below the 0.9 MWh target (delta is negative) assert unresolved_targets["soc-minima"]["delta"] < 0 - # Confirm the datetime is the end of the schedule - assert unresolved_targets["soc-minima"]["datetime"].startswith("2015-01-01T") + # The constraint is at 2015-01-02T00:00:00+01:00 = 2015-01-01T23:00:00+00:00 (UTC) + assert unresolved_targets["soc-minima"]["datetime"] == "2015-01-01T23:00:00+00:00" # No soc-maxima was set, so it should not appear assert "soc-maxima" not in unresolved_targets diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index 8bf7cad739..fe2683b187 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -15,6 +15,24 @@ class SchedulingJobResult: """ unresolved_targets: dict = field(default_factory=dict) + """First unmet ``soc-minima`` and/or ``soc-maxima`` targets, if any. + + Each present key maps to a dict with: + + - ``"datetime"``: ISO 8601 timestamp of the first violated constraint. + - ``"delta"``: Signed difference (scheduled SoC minus target value) in MWh. + A negative ``delta`` for ``soc-minima`` means the SoC is below the minimum; + a positive ``delta`` for ``soc-maxima`` means the SoC exceeds the maximum. + + Example:: + + { + "soc-minima": {"datetime": "2024-01-01T10:00:00+00:00", "delta": -0.5}, + "soc-maxima": {"datetime": "2024-01-01T14:00:00+00:00", "delta": 0.3}, + } + + If a constraint type has no violation the key is absent. + """ def to_dict(self) -> dict: """Serialize to a JSON-compatible dict.""" From 654743ae6bf4ce4f960e18330e70f2a2d600aa8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:27:31 +0000 Subject: [PATCH 04/11] fix: address coordinator findings in unresolved targets feature - Fix multi-device ordering bug: now finds earliest violation across ALL devices instead of stopping at first device with a violation - Normalize violation datetime to UTC in isoformat() output - Round soc_schedule_mwh to round_to_decimals precision before comparison to avoid epsilon false positives from floating-point arithmetic - Add test_unresolved_targets_soc_maxima to cover the soc-maxima violation path Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/710e6bc9-87d9-4238-9c3f-c79a445aff3e Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- flexmeasures/data/models/planning/storage.py | 55 ++++++++++----- .../models/planning/tests/test_storage.py | 68 +++++++++++++++++++ 2 files changed, 107 insertions(+), 16 deletions(-) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 47e775d918..ae5be41912 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1390,10 +1390,8 @@ def _compute_unresolved_targets( """Compute the first unmet SoC minima and maxima targets across all scheduled devices. For each device that has ``soc-minima`` or ``soc-maxima`` constraints in the flex model, - compares the computed MWh SoC schedule against those constraints. The first (earliest - in time) violation of each type is reported, across all devices. Once the first - violation of a given type is found for any device, subsequent devices are skipped for - that type. + compares the computed MWh SoC schedule against those constraints. The earliest-in-time + violation of each type, *across all devices*, is returned. Constraints are evaluated over the window ``(start + resolution, end)`` (i.e. the first scheduled slot through the end of the schedule). The ``start`` slot itself is @@ -1408,12 +1406,16 @@ def _compute_unresolved_targets( :param end: End of the schedule. :param resolution: Schedule resolution. :returns: dict with keys ``"soc-minima"`` and/or ``"soc-maxima"``, each containing - ``{"datetime": , "delta": }`` for the first + ``{"datetime": , "delta": }`` for the first unmet target. ``delta`` equals scheduled SoC minus target value (negative means the SoC is below the minimum; positive means it exceeds the maximum). """ - result: dict = {} precision = self.round_to_decimals if self.round_to_decimals is not None else 6 + # Collect the earliest violation per constraint type across all devices. + earliest_minima: dict | None = None + earliest_minima_time: pd.Timestamp | None = None + earliest_maxima: dict | None = None + earliest_maxima_time: pd.Timestamp | None = None for d, flex_model_d in enumerate(flex_model): soc_mwh = soc_schedule_mwh.get(d) @@ -1422,7 +1424,7 @@ def _compute_unresolved_targets( # Check soc_minima (first time slot where scheduled SoC < minima) soc_minima_d = flex_model_d.get("soc_minima") - if soc_minima_d is not None and "soc-minima" not in result: + if soc_minima_d is not None: soc_minima_series = get_continuous_series_sensor_or_quantity( variable_quantity=soc_minima_d, unit="MWh", @@ -1439,14 +1441,19 @@ def _compute_unresolved_targets( violations = deltas[deltas < 0] if not violations.empty: first_t = violations.index[0] - result["soc-minima"] = { - "datetime": first_t.isoformat(), - "delta": round(float(deltas[first_t]), precision), - } + if ( + earliest_minima_time is None + or first_t < earliest_minima_time + ): + earliest_minima_time = first_t + earliest_minima = { + "datetime": first_t.tz_convert("UTC").isoformat(), + "delta": round(float(deltas[first_t]), precision), + } # Check soc_maxima (first time slot where scheduled SoC > maxima) soc_maxima_d = flex_model_d.get("soc_maxima") - if soc_maxima_d is not None and "soc-maxima" not in result: + if soc_maxima_d is not None: soc_maxima_series = get_continuous_series_sensor_or_quantity( variable_quantity=soc_maxima_d, unit="MWh", @@ -1463,11 +1470,21 @@ def _compute_unresolved_targets( violations = deltas[deltas > 0] if not violations.empty: first_t = violations.index[0] - result["soc-maxima"] = { - "datetime": first_t.isoformat(), - "delta": round(float(deltas[first_t]), precision), - } + if ( + earliest_maxima_time is None + or first_t < earliest_maxima_time + ): + earliest_maxima_time = first_t + earliest_maxima = { + "datetime": first_t.tz_convert("UTC").isoformat(), + "delta": round(float(deltas[first_t]), precision), + } + result: dict = {} + if earliest_minima is not None: + result["soc-minima"] = earliest_minima + if earliest_maxima is not None: + result["soc-maxima"] = earliest_maxima return result def compute(self, skip_validation: bool = False) -> SchedulerOutputType: @@ -1564,6 +1581,12 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: sensor: soc_schedule[sensor].round(self.round_to_decimals) for sensor in soc_schedule.keys() } + # Round the MWh SoC schedule to the same precision so that violation + # detection does not flag floating-point epsilon differences. + soc_schedule_mwh = { + d: series.round(self.round_to_decimals) + for d, series in soc_schedule_mwh.items() + } if self.return_multiple: unresolved_targets = self._compute_unresolved_targets( diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 4cfc797afe..dc5d840a20 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -377,3 +377,71 @@ def test_unresolved_targets_none_when_met(add_battery_assets, db): # The minima target is met, so no unresolved targets expected assert "soc-minima" not in unresolved_targets assert "soc-maxima" not in unresolved_targets + + +def test_unresolved_targets_soc_maxima(add_battery_assets, db): + """Test that unresolved soc-maxima targets are reported in the scheduling result. + + A battery starts at 0.9 MWh with a very limited discharge capacity (0.01 MW). + With 100% efficiency and 24 hours, it can discharge at most 0.01 * 24 = 0.24 MWh, + reaching a min SoC of ~0.66 MWh. No roundtrip or storage efficiency is set, + so the default (100%) applies. + A soc-maxima of 0.5 MWh is set as a soft constraint (via a breach price). + The scheduler will discharge at full capacity but still remain above the target, + so the scheduling result should report an unresolved soc-maxima. + """ + _, battery = get_sensors_from_db( + db, add_battery_assets, battery_name="Test battery" + ) + tz = pytz.timezone("Europe/Amsterdam") + start = tz.localize(datetime(2015, 1, 1)) + end = tz.localize(datetime(2015, 1, 2)) + resolution = timedelta(minutes=15) + soc_at_start = 0.9 + index = initialize_index(start=start, end=end, resolution=resolution) + consumption_prices = pd.Series(100, index=index) + + scheduler: Scheduler = StorageScheduler( + battery, + start, + end, + resolution, + flex_model={ + "soc-at-start": f"{soc_at_start} MWh", + "soc-min": "0 MWh", + "soc-max": "1 MWh", + "power-capacity": "0.01 MVA", # very limited: max discharge 0.24 MWh over 24 h + "soc-maxima": [ + { + "datetime": "2015-01-02T00:00:00+01:00", + "value": "0.5 MWh", # unreachably low + } + ], + "prefer-charging-sooner": False, + }, + flex_context={ + "consumption-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"), + "production-price": series_to_ts_specs(consumption_prices, unit="EUR/MWh"), + "site-power-capacity": "2 MW", + "soc-maxima-breach-price": "1 EUR/kWh", # soft constraint + }, + return_multiple=True, + ) + results = scheduler.compute() + + scheduling_result_entry = next( + (r for r in results if r.get("name") == "scheduling_result"), None + ) + assert scheduling_result_entry is not None + + unresolved_targets = scheduling_result_entry["data"].unresolved_targets + assert ( + "soc-maxima" in unresolved_targets + ), "Expected an unresolved soc-maxima since the target is unreachable" + # The scheduled SoC should be above the 0.5 MWh target (delta is positive) + assert unresolved_targets["soc-maxima"]["delta"] > 0 + # The constraint is at 2015-01-02T00:00:00+01:00 = 2015-01-01T23:00:00+00:00 (UTC) + assert unresolved_targets["soc-maxima"]["datetime"] == "2015-01-01T23:00:00+00:00" + + # No soc-minima was set, so it should not appear + assert "soc-minima" not in unresolved_targets From 633fd875fc1cfe52991f58b345c60515111e24a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:32:54 +0000 Subject: [PATCH 05/11] refactor: introduce SCHEDULING_RESULT_KEY constant and clean up precision comment - Define SCHEDULING_RESULT_KEY constant in storage.py to avoid magic strings - Use the constant in compute(), make_schedule(), and get_schedule API - Add explanatory comment for round_to_decimals fallback precision Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/710e6bc9-87d9-4238-9c3f-c79a445aff3e Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- flexmeasures/api/v3_0/sensors.py | 3 ++- flexmeasures/data/models/planning/storage.py | 7 ++++++- flexmeasures/data/services/scheduling.py | 9 ++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 29dd54fd78..3b40e4058f 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -66,6 +66,7 @@ create_scheduling_job, get_data_source_for_job, ) +from flexmeasures.data.models.planning.storage import SCHEDULING_RESULT_KEY from flexmeasures.utils.time_utils import duration_isoformat from flexmeasures.utils.flexmeasures_inflection import join_words_into_a_list from flexmeasures.utils.unit_utils import convert_units @@ -1107,7 +1108,7 @@ def get_schedule( # noqa: C901 # Returns None if the job predates the scheduling_result feature (no meta key), # or the dict with unresolved_targets if computed. - scheduling_result = job.meta.get("scheduling_result") + scheduling_result = job.meta.get(SCHEDULING_RESULT_KEY) d, s = request_processed(scheduler_info_msg) return ( dict( diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index ae5be41912..c095b54606 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -45,6 +45,10 @@ storage_asset_types = ["one-way_evse", "two-way_evse", "battery", "heat-storage"] +#: Key used to store and retrieve the ``SchedulingJobResult`` in RQ job metadata +#: and in the multi-result list returned by ``StorageScheduler.compute()``. +SCHEDULING_RESULT_KEY = "scheduling_result" + class MetaStorageScheduler(Scheduler): """This class defines the constraints of a schedule for a storage device from the @@ -1410,6 +1414,7 @@ def _compute_unresolved_targets( unmet target. ``delta`` equals scheduled SoC minus target value (negative means the SoC is below the minimum; positive means it exceeds the maximum). """ + # Use the configured rounding precision, or the scheduler's default of 6. precision = self.round_to_decimals if self.round_to_decimals is not None else 6 # Collect the earliest violation per constraint type across all devices. earliest_minima: dict | None = None @@ -1625,7 +1630,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: ] scheduling_result = [ { - "name": "scheduling_result", + "name": SCHEDULING_RESULT_KEY, "data": SchedulingJobResult(unresolved_targets=unresolved_targets), } ] diff --git a/flexmeasures/data/services/scheduling.py b/flexmeasures/data/services/scheduling.py index c719a3b1d0..e4a88b9b8b 100644 --- a/flexmeasures/data/services/scheduling.py +++ b/flexmeasures/data/services/scheduling.py @@ -27,7 +27,10 @@ from flexmeasures.data import db from flexmeasures.data.models.planning import Scheduler, SchedulerOutputType -from flexmeasures.data.models.planning.storage import StorageScheduler +from flexmeasures.data.models.planning.storage import ( + StorageScheduler, + SCHEDULING_RESULT_KEY, +) from flexmeasures.data.models.planning.exceptions import InfeasibleProblemException from flexmeasures.data.models.planning.process import ProcessScheduler from flexmeasures.data.models.time_series import Sensor, TimedBelief @@ -632,8 +635,8 @@ def make_schedule( # noqa: C901 # Save any result that specifies a sensor to save it to for result in consumption_schedule: - if result.get("name") == "scheduling_result" and rq_job: - rq_job.meta["scheduling_result"] = result["data"].to_dict() + if result.get("name") == SCHEDULING_RESULT_KEY and rq_job: + rq_job.meta[SCHEDULING_RESULT_KEY] = result["data"].to_dict() rq_job.save_meta() continue if "sensor" not in result: From 36498049295c4b4181d3d1eb70576d56098c100b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:10:39 +0000 Subject: [PATCH 06/11] scheduling: track unmet targets per sensor with positive kWh deltas Context: - Review feedback on the 'compute first unmet targets' feature requested per-sensor tracking, always-positive deltas with units, and omitting scheduling_result from the API response for legacy jobs. Change: - SchedulingJobResult.unresolved_targets is now keyed by sensor ID string (SoC sensor if available, else power sensor), with per-device constraint violations; an empty dict means all targets were met. - _compute_unresolved_targets returns per-device violations only (no cross- device earliest logic); delta is always positive in kWh as a string. - sensors.py omits scheduling_result from the response entirely for legacy jobs (was returning null); OpenAPI description updated accordingly. - Tests updated to assert the new structure and exact delta values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- flexmeasures/api/v3_0/sensors.py | 36 +++++---- flexmeasures/data/models/planning/storage.py | 77 +++++++++---------- .../models/planning/tests/test_storage.py | 31 +++++--- .../data/services/scheduling_result.py | 25 +++--- 4 files changed, 95 insertions(+), 74 deletions(-) diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 3b40e4058f..3ee808984c 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -933,18 +933,24 @@ def get_schedule( # noqa: C901 additionalProperties: true scheduling_result: - nullable: true type: object description: | - Additional results produced by the scheduler, or ``null`` for jobs - created before this field was introduced. + Additional results produced by the scheduler. + This field is left out for jobs created before this field was introduced. The ``unresolved_targets`` field reports the first time at which the - scheduled state of charge (SoC) violates a soft SoC constraint, along - with the signed difference (scheduled SoC minus target value). - A negative ``delta`` for ``soc-minima`` means the SoC is below the - minimum; a positive ``delta`` for ``soc-maxima`` means the SoC exceeds - the maximum. + scheduled state of charge (SoC) violates a soft SoC constraint, + per sensor (keyed by sensor ID string). An empty ``unresolved_targets`` + dict means all targets have been met. + + Each per-sensor entry may have ``"soc-minima"`` and/or ``"soc-maxima"`` + sub-keys (only present when a violation exists), each with: + + - ``"datetime"``: ISO 8601 UTC timestamp of the first violation. + - ``"delta"``: Always-positive magnitude in kWh, e.g. ``"260.0 kWh"``. + For ``soc-minima`` this is the shortage (SoC fell short by this amount); + for ``soc-maxima`` this is the excess (SoC exceeded the target by this + amount). Note: ``soc-targets`` are modelled as hard constraints, so the scheduler will never allow a deviation from them by definition. @@ -1110,13 +1116,15 @@ def get_schedule( # noqa: C901 # or the dict with unresolved_targets if computed. scheduling_result = job.meta.get(SCHEDULING_RESULT_KEY) d, s = request_processed(scheduler_info_msg) + response_body = dict( + scheduler_info=scheduler_info, + **response, + **d, + ) + if scheduling_result is not None: + response_body["scheduling_result"] = scheduling_result return ( - dict( - scheduler_info=scheduler_info, - scheduling_result=scheduling_result, - **response, - **d, - ), + response_body, s, ) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index c095b54606..b2b798f372 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1391,11 +1391,11 @@ def _compute_unresolved_targets( end: datetime, resolution: timedelta, ) -> dict: - """Compute the first unmet SoC minima and maxima targets across all scheduled devices. + """Compute the first unmet SoC minima and maxima targets per device. For each device that has ``soc-minima`` or ``soc-maxima`` constraints in the flex model, - compares the computed MWh SoC schedule against those constraints. The earliest-in-time - violation of each type, *across all devices*, is returned. + compares the computed MWh SoC schedule against those constraints and records the first + violation per constraint type for that device. Constraints are evaluated over the window ``(start + resolution, end)`` (i.e. the first scheduled slot through the end of the schedule). The ``start`` slot itself is @@ -1409,24 +1409,33 @@ def _compute_unresolved_targets( :param start: Start of the schedule. :param end: End of the schedule. :param resolution: Schedule resolution. - :returns: dict with keys ``"soc-minima"`` and/or ``"soc-maxima"``, each containing - ``{"datetime": , "delta": }`` for the first - unmet target. ``delta`` equals scheduled SoC minus target value (negative - means the SoC is below the minimum; positive means it exceeds the maximum). + :returns: dict keyed by sensor ID string (state-of-charge sensor if available, + else power sensor). Each value is a dict with keys ``"soc-minima"`` + and/or ``"soc-maxima"`` (only present when a violation exists), each + containing ``{"datetime": , "delta": " kWh"}`` + where ``delta`` is always positive: the shortage for ``soc-minima`` and + the excess for ``soc-maxima``. An empty dict means all targets were met. """ # Use the configured rounding precision, or the scheduler's default of 6. precision = self.round_to_decimals if self.round_to_decimals is not None else 6 - # Collect the earliest violation per constraint type across all devices. - earliest_minima: dict | None = None - earliest_minima_time: pd.Timestamp | None = None - earliest_maxima: dict | None = None - earliest_maxima_time: pd.Timestamp | None = None + + result: dict = {} for d, flex_model_d in enumerate(flex_model): soc_mwh = soc_schedule_mwh.get(d) if soc_mwh is None: continue + # Determine the key for this device: prefer SoC sensor, fall back to power sensor. + state_of_charge_sensor = flex_model_d.get("state_of_charge") + if isinstance(state_of_charge_sensor, Sensor): + device_key = str(state_of_charge_sensor.id) + else: + power_sensor = flex_model_d.get("sensor") + device_key = str(power_sensor.id) + + device_violations: dict = {} + # Check soc_minima (first time slot where scheduled SoC < minima) soc_minima_d = flex_model_d.get("soc_minima") if soc_minima_d is not None: @@ -1442,19 +1451,15 @@ def _compute_unresolved_targets( defined_minima = soc_minima_series.dropna() if len(defined_minima) > 0: aligned_soc = soc_mwh.reindex(defined_minima.index) - deltas = aligned_soc - defined_minima - violations = deltas[deltas < 0] + shortages = defined_minima - aligned_soc + violations = shortages[shortages > 0] if not violations.empty: first_t = violations.index[0] - if ( - earliest_minima_time is None - or first_t < earliest_minima_time - ): - earliest_minima_time = first_t - earliest_minima = { - "datetime": first_t.tz_convert("UTC").isoformat(), - "delta": round(float(deltas[first_t]), precision), - } + delta_kwh = round(float(violations[first_t]) * 1000, precision) + device_violations["soc-minima"] = { + "datetime": first_t.tz_convert("UTC").isoformat(), + "delta": f"{delta_kwh} kWh", + } # Check soc_maxima (first time slot where scheduled SoC > maxima) soc_maxima_d = flex_model_d.get("soc_maxima") @@ -1471,25 +1476,19 @@ def _compute_unresolved_targets( defined_maxima = soc_maxima_series.dropna() if len(defined_maxima) > 0: aligned_soc = soc_mwh.reindex(defined_maxima.index) - deltas = aligned_soc - defined_maxima - violations = deltas[deltas > 0] + excesses = aligned_soc - defined_maxima + violations = excesses[excesses > 0] if not violations.empty: first_t = violations.index[0] - if ( - earliest_maxima_time is None - or first_t < earliest_maxima_time - ): - earliest_maxima_time = first_t - earliest_maxima = { - "datetime": first_t.tz_convert("UTC").isoformat(), - "delta": round(float(deltas[first_t]), precision), - } + delta_kwh = round(float(violations[first_t]) * 1000, precision) + device_violations["soc-maxima"] = { + "datetime": first_t.tz_convert("UTC").isoformat(), + "delta": f"{delta_kwh} kWh", + } + + if device_violations: + result[device_key] = device_violations - result: dict = {} - if earliest_minima is not None: - result["soc-minima"] = earliest_minima - if earliest_maxima is not None: - result["soc-maxima"] = earliest_maxima return result def compute(self, skip_validation: bool = False) -> SchedulerOutputType: diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index dc5d840a20..3319d5dce7 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -312,15 +312,19 @@ def test_unresolved_targets_soc_minima(add_battery_assets, db): unresolved_targets = scheduling_result.unresolved_targets assert ( - "soc-minima" in unresolved_targets + str(battery.id) in unresolved_targets ), "Expected an unresolved soc-minima since the target is unreachable" - # The scheduled SoC should be below the 0.9 MWh target (delta is negative) - assert unresolved_targets["soc-minima"]["delta"] < 0 + assert "soc-minima" in unresolved_targets[str(battery.id)] + # The scheduled SoC should be below the 0.9 MWh target (delta == 260.0 kWh shortage) + assert unresolved_targets[str(battery.id)]["soc-minima"]["delta"] == "260.0 kWh" # The constraint is at 2015-01-02T00:00:00+01:00 = 2015-01-01T23:00:00+00:00 (UTC) - assert unresolved_targets["soc-minima"]["datetime"] == "2015-01-01T23:00:00+00:00" + assert ( + unresolved_targets[str(battery.id)]["soc-minima"]["datetime"] + == "2015-01-01T23:00:00+00:00" + ) # No soc-maxima was set, so it should not appear - assert "soc-maxima" not in unresolved_targets + assert "soc-maxima" not in unresolved_targets[str(battery.id)] def test_unresolved_targets_none_when_met(add_battery_assets, db): @@ -375,8 +379,7 @@ def test_unresolved_targets_none_when_met(add_battery_assets, db): assert scheduling_result_entry is not None unresolved_targets = scheduling_result_entry["data"].unresolved_targets # The minima target is met, so no unresolved targets expected - assert "soc-minima" not in unresolved_targets - assert "soc-maxima" not in unresolved_targets + assert unresolved_targets == {} def test_unresolved_targets_soc_maxima(add_battery_assets, db): @@ -436,12 +439,16 @@ def test_unresolved_targets_soc_maxima(add_battery_assets, db): unresolved_targets = scheduling_result_entry["data"].unresolved_targets assert ( - "soc-maxima" in unresolved_targets + str(battery.id) in unresolved_targets ), "Expected an unresolved soc-maxima since the target is unreachable" - # The scheduled SoC should be above the 0.5 MWh target (delta is positive) - assert unresolved_targets["soc-maxima"]["delta"] > 0 + assert "soc-maxima" in unresolved_targets[str(battery.id)] + # The scheduled SoC should be above the 0.5 MWh target (delta == 160.0 kWh excess) + assert unresolved_targets[str(battery.id)]["soc-maxima"]["delta"] == "160.0 kWh" # The constraint is at 2015-01-02T00:00:00+01:00 = 2015-01-01T23:00:00+00:00 (UTC) - assert unresolved_targets["soc-maxima"]["datetime"] == "2015-01-01T23:00:00+00:00" + assert ( + unresolved_targets[str(battery.id)]["soc-maxima"]["datetime"] + == "2015-01-01T23:00:00+00:00" + ) # No soc-minima was set, so it should not appear - assert "soc-minima" not in unresolved_targets + assert "soc-minima" not in unresolved_targets[str(battery.id)] diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index fe2683b187..2f8a110952 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -15,23 +15,30 @@ class SchedulingJobResult: """ unresolved_targets: dict = field(default_factory=dict) - """First unmet ``soc-minima`` and/or ``soc-maxima`` targets, if any. + """First unmet ``soc-minima`` and/or ``soc-maxima`` targets, per sensor. - Each present key maps to a dict with: + The outer dict is keyed by sensor ID string (``str(sensor.id)``): the + state-of-charge sensor if the device has one, otherwise the power sensor. + Each value is a dict with constraint-type keys (``"soc-minima"`` and/or + ``"soc-maxima"``), each mapping to: - - ``"datetime"``: ISO 8601 timestamp of the first violated constraint. - - ``"delta"``: Signed difference (scheduled SoC minus target value) in MWh. - A negative ``delta`` for ``soc-minima`` means the SoC is below the minimum; - a positive ``delta`` for ``soc-maxima`` means the SoC exceeds the maximum. + - ``"datetime"``: ISO 8601 UTC timestamp of the first violated constraint. + - ``"delta"``: Always-positive magnitude of the violation in kWh, + formatted as e.g. ``"260.0 kWh"``. + For ``soc-minima`` this is the shortage (SoC fell short by this amount); + for ``soc-maxima`` this is the excess (SoC exceeded the target by this amount). + + An empty dict means all targets have been met. Example:: { - "soc-minima": {"datetime": "2024-01-01T10:00:00+00:00", "delta": -0.5}, - "soc-maxima": {"datetime": "2024-01-01T14:00:00+00:00", "delta": 0.3}, + "42": { + "soc-minima": {"datetime": "2024-01-01T10:00:00+00:00", "delta": "260.0 kWh"}, + }, } - If a constraint type has no violation the key is absent. + Devices with no violations are absent from the outer dict. """ def to_dict(self) -> dict: From 4932e280d8ba2ae1ead8b213f54971367cfbdf39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 13:12:01 +0000 Subject: [PATCH 07/11] scheduling: guard against missing power sensor in _compute_unresolved_targets Change: - Skip devices where neither SoC sensor nor power sensor is available, rather than crashing with AttributeError on None.id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- flexmeasures/data/models/planning/storage.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index b2b798f372..71ab58c40b 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1432,6 +1432,8 @@ def _compute_unresolved_targets( device_key = str(state_of_charge_sensor.id) else: power_sensor = flex_model_d.get("sensor") + if power_sensor is None: + continue device_key = str(power_sensor.id) device_violations: dict = {} From 02dfd42f3b340b8541290b0c4cd3e175d8b1b5ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:30:15 +0000 Subject: [PATCH 08/11] =?UTF-8?q?scheduling:=20add=20resolved=5Ftargets=20?= =?UTF-8?q?and=20rename=20delta=E2=86=92unmet=20in=20scheduling=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: - Review feedback on the "compute first unmet targets" feature - unresolved_targets previously used "delta" key and fell back to power sensor when no SoC sensor was set Change: - Rename "delta" → "unmet" in unresolved_targets entries for clarity - Add resolved_targets field: tracks soft constraints that WERE met, reporting the tightest (smallest margin) slot per sensor - Only use state-of-charge sensors as keys; skip devices without one - _compute_unresolved_targets now returns (unresolved, resolved) tuple - Update to_dict/from_dict to include resolved_targets - Update OpenAPI docstring in sensors.py for both fields - Update tests: add SoC sensor fixtures with unique names, update assertions to use "unmet" key and check resolved_targets - Add "The schedule" section to scheduling.rst documenting both fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- documentation/features/scheduling.rst | 36 +++++++ flexmeasures/api/v3_0/sensors.py | 32 +++++-- flexmeasures/data/models/planning/storage.py | 96 ++++++++++++------- .../models/planning/tests/test_storage.py | 74 +++++++++++--- .../data/services/scheduling_result.py | 46 +++++++-- 5 files changed, 223 insertions(+), 61 deletions(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index 3801f2bda0..f6493ff44a 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -317,6 +317,42 @@ You can add new shiftable-process schedules with the CLI command ``flexmeasures .. note:: Currently, the ``ProcessScheduler`` uses only the ``consumption-price`` field of the flex-context, so it ignores any site capacities and inflexible devices. +The schedule +------------ + +A schedule produced by FlexMeasures is a series of power values for each flexible device (represented by its power sensor), covering the scheduling window at the scheduling resolution. + +Besides the power values themselves, FlexMeasures also returns additional scheduling metadata in a ``scheduling_result`` field. This field is populated when the device has a ``state-of-charge`` sensor configured (via the ``state-of-charge`` field in the flex model). + +**Unresolved targets** (``unresolved_targets``) + +The ``unresolved_targets`` field lists soft SoC constraints (``soc-minima`` and/or ``soc-maxima``) that could *not* be satisfied, keyed by state-of-charge sensor ID. For each violated constraint type, it reports: + +- ``"datetime"``: the ISO 8601 UTC timestamp of the first violation. +- ``"unmet"``: the magnitude of the violation in kWh (always positive). + For ``soc-minima`` this is the shortage (SoC fell short by this amount); + for ``soc-maxima`` this is the excess (SoC exceeded the target by this amount). + +An empty ``{}`` means all constraints of that type were satisfied (or none were defined). + +*Example use case*: for EV charging, if the battery could not be fully charged for a planned trip, the ``unresolved_targets`` field will report how much charge is missing. The fleet operator can then plan to use public charge points to make up the difference. + +**Resolved targets** (``resolved_targets``) + +The ``resolved_targets`` field lists soft SoC constraints that *were* satisfied, keyed by state-of-charge sensor ID. For each met constraint type, it reports the tightest (smallest-margin) slot: + +- ``"datetime"``: the ISO 8601 UTC timestamp of the tightest constraint slot. +- ``"margin"``: the headroom available at that slot in kWh (always positive). + For ``soc-minima`` this is how far above the minimum the SoC was; + for ``soc-maxima`` this is how far below the maximum the SoC was. + +An empty ``{}`` means no constraints of that type were defined. + +.. note:: Setting a ``state-of-charge`` sensor on the device is required to populate ``unresolved_targets`` and ``resolved_targets``. + +For full technical details of the response schema, refer to the API endpoint documentation for ``GET /sensors//schedules/``. + + Work on other schedulers -------------------------- diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 3ee808984c..1f1eaf3bbd 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -938,19 +938,35 @@ def get_schedule( # noqa: C901 Additional results produced by the scheduler. This field is left out for jobs created before this field was introduced. - The ``unresolved_targets`` field reports the first time at which the - scheduled state of charge (SoC) violates a soft SoC constraint, - per sensor (keyed by sensor ID string). An empty ``unresolved_targets`` - dict means all targets have been met. + Requires a ``state-of-charge`` sensor to be set on the device. + + The ``unresolved_targets`` field lists soft SoC constraints that could + not be satisfied, keyed by state-of-charge sensor ID string. + An empty ``{}`` means all targets were met (or no constraints were + defined). Each per-sensor entry may have ``"soc-minima"`` and/or ``"soc-maxima"`` sub-keys (only present when a violation exists), each with: - ``"datetime"``: ISO 8601 UTC timestamp of the first violation. - - ``"delta"``: Always-positive magnitude in kWh, e.g. ``"260.0 kWh"``. - For ``soc-minima`` this is the shortage (SoC fell short by this amount); - for ``soc-maxima`` this is the excess (SoC exceeded the target by this - amount). + - ``"unmet"``: Always-positive shortage/excess in kWh, e.g. + ``"260.0 kWh"``. For ``soc-minima`` this is the shortage (SoC fell + short by this amount); for ``soc-maxima`` this is the excess (SoC + exceeded the target by this amount). + + The ``resolved_targets`` field lists soft SoC constraints that WERE + satisfied, keyed by state-of-charge sensor ID string. + An empty ``{}`` means no constraints of that type were defined. + + Each per-sensor entry may have ``"soc-minima"`` and/or ``"soc-maxima"`` + sub-keys (only present when the constraint type was defined and met), + each with: + + - ``"datetime"``: ISO 8601 UTC timestamp of the tightest constraint + slot (smallest positive margin). + - ``"margin"``: Always-positive headroom in kWh, e.g. ``"40.0 kWh"``. + For ``soc-minima`` this is how far above the minimum the SoC was; + for ``soc-maxima`` this is how far below the maximum the SoC was. Note: ``soc-targets`` are modelled as hard constraints, so the scheduler will never allow a deviation from them by definition. diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 71ab58c40b..70c951ab6c 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1390,53 +1390,60 @@ def _compute_unresolved_targets( start: datetime, end: datetime, resolution: timedelta, - ) -> dict: - """Compute the first unmet SoC minima and maxima targets per device. + ) -> tuple[dict, dict]: + """Compute unmet and met SoC minima/maxima targets per device. - For each device that has ``soc-minima`` or ``soc-maxima`` constraints in the flex model, - compares the computed MWh SoC schedule against those constraints and records the first - violation per constraint type for that device. + For each device that has a ``state_of_charge`` Sensor and ``soc-minima`` + or ``soc-maxima`` constraints in the flex model, compares the computed MWh + SoC schedule against those constraints. Devices without a + ``state_of_charge`` Sensor are skipped. - Constraints are evaluated over the window ``(start + resolution, end)`` (i.e. the - first scheduled slot through the end of the schedule). The ``start`` slot itself is - the initial condition (``soc_at_start``), not a scheduled value, so it is excluded. + Constraints are evaluated over the window ``(start + resolution, end)`` (i.e. + the first scheduled slot through the end of the schedule). The ``start`` + slot itself is the initial condition (``soc_at_start``), not a scheduled + value, so it is excluded. - Note: ``soc-targets`` are modelled as hard constraints and are not checked here, - as by definition the scheduler will not allow any deviation from them. + Note: ``soc-targets`` are modelled as hard constraints and are not checked + here, as by definition the scheduler will not allow any deviation from them. :param flex_model: The deserialized flex model (list of per-device dicts). :param soc_schedule_mwh: MWh SoC schedule keyed by device index ``d``. :param start: Start of the schedule. :param end: End of the schedule. :param resolution: Schedule resolution. - :returns: dict keyed by sensor ID string (state-of-charge sensor if available, - else power sensor). Each value is a dict with keys ``"soc-minima"`` - and/or ``"soc-maxima"`` (only present when a violation exists), each - containing ``{"datetime": , "delta": " kWh"}`` - where ``delta`` is always positive: the shortage for ``soc-minima`` and - the excess for ``soc-maxima``. An empty dict means all targets were met. + :returns: A tuple ``(unresolved_targets, resolved_targets)``. + + ``unresolved_targets`` is keyed by state-of-charge sensor ID string. + Each value is a dict with keys ``"soc-minima"`` and/or ``"soc-maxima"`` + (only present when a violation exists), each containing + ``{"datetime": , "unmet": " kWh"}`` + where ``unmet`` is always positive. + + ``resolved_targets`` is also keyed by state-of-charge sensor ID string. + Each value is a dict with keys ``"soc-minima"`` and/or ``"soc-maxima"`` + (only present when the constraint type was defined and fully met), each + containing ``{"datetime": , "margin": " kWh"}`` + for the slot with the tightest (smallest positive) margin. """ # Use the configured rounding precision, or the scheduler's default of 6. precision = self.round_to_decimals if self.round_to_decimals is not None else 6 - result: dict = {} + unresolved: dict = {} + resolved: dict = {} for d, flex_model_d in enumerate(flex_model): soc_mwh = soc_schedule_mwh.get(d) if soc_mwh is None: continue - # Determine the key for this device: prefer SoC sensor, fall back to power sensor. + # Only use state-of-charge sensors as keys; skip devices without one. state_of_charge_sensor = flex_model_d.get("state_of_charge") - if isinstance(state_of_charge_sensor, Sensor): - device_key = str(state_of_charge_sensor.id) - else: - power_sensor = flex_model_d.get("sensor") - if power_sensor is None: - continue - device_key = str(power_sensor.id) + if not isinstance(state_of_charge_sensor, Sensor): + continue + device_key = str(state_of_charge_sensor.id) device_violations: dict = {} + device_resolved: dict = {} # Check soc_minima (first time slot where scheduled SoC < minima) soc_minima_d = flex_model_d.get("soc_minima") @@ -1457,10 +1464,19 @@ def _compute_unresolved_targets( violations = shortages[shortages > 0] if not violations.empty: first_t = violations.index[0] - delta_kwh = round(float(violations[first_t]) * 1000, precision) + unmet_kwh = round(float(violations[first_t]) * 1000, precision) device_violations["soc-minima"] = { "datetime": first_t.tz_convert("UTC").isoformat(), - "delta": f"{delta_kwh} kWh", + "unmet": f"{unmet_kwh} kWh", + } + else: + # All minima met — record the tightest margin (min headroom above min) + margins = aligned_soc - defined_minima + tightest_t = margins.idxmin() + margin_kwh = round(float(margins[tightest_t]) * 1000, precision) + device_resolved["soc-minima"] = { + "datetime": tightest_t.tz_convert("UTC").isoformat(), + "margin": f"{margin_kwh} kWh", } # Check soc_maxima (first time slot where scheduled SoC > maxima) @@ -1482,16 +1498,27 @@ def _compute_unresolved_targets( violations = excesses[excesses > 0] if not violations.empty: first_t = violations.index[0] - delta_kwh = round(float(violations[first_t]) * 1000, precision) + unmet_kwh = round(float(violations[first_t]) * 1000, precision) device_violations["soc-maxima"] = { "datetime": first_t.tz_convert("UTC").isoformat(), - "delta": f"{delta_kwh} kWh", + "unmet": f"{unmet_kwh} kWh", + } + else: + # All maxima met — record the tightest margin (min headroom below max) + margins = defined_maxima - aligned_soc + tightest_t = margins.idxmin() + margin_kwh = round(float(margins[tightest_t]) * 1000, precision) + device_resolved["soc-maxima"] = { + "datetime": tightest_t.tz_convert("UTC").isoformat(), + "margin": f"{margin_kwh} kWh", } if device_violations: - result[device_key] = device_violations + unresolved[device_key] = device_violations + if device_resolved: + resolved[device_key] = device_resolved - return result + return unresolved, resolved def compute(self, skip_validation: bool = False) -> SchedulerOutputType: """Schedule a battery or Charge Point based directly on the latest beliefs regarding market prices within the specified time window. @@ -1595,7 +1622,7 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: } if self.return_multiple: - unresolved_targets = self._compute_unresolved_targets( + unresolved_targets, resolved_targets = self._compute_unresolved_targets( flex_model, soc_schedule_mwh, start, end, resolution ) storage_schedules = [ @@ -1632,7 +1659,10 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: scheduling_result = [ { "name": SCHEDULING_RESULT_KEY, - "data": SchedulingJobResult(unresolved_targets=unresolved_targets), + "data": SchedulingJobResult( + unresolved_targets=unresolved_targets, + resolved_targets=resolved_targets, + ), } ] return ( diff --git a/flexmeasures/data/models/planning/tests/test_storage.py b/flexmeasures/data/models/planning/tests/test_storage.py index 3319d5dce7..56d42d7efe 100644 --- a/flexmeasures/data/models/planning/tests/test_storage.py +++ b/flexmeasures/data/models/planning/tests/test_storage.py @@ -13,6 +13,7 @@ get_sensors_from_db, series_to_ts_specs, ) +from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.services.scheduling_result import SchedulingJobResult @@ -265,6 +266,15 @@ def test_unresolved_targets_soc_minima(add_battery_assets, db): _, battery = get_sensors_from_db( db, add_battery_assets, battery_name="Test battery" ) + soc_sensor = Sensor( + name="state-of-charge-minima-test", + generic_asset=battery.generic_asset, + unit="MWh", + event_resolution=timedelta(0), + ) + db.session.add(soc_sensor) + db.session.flush() + tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -289,6 +299,7 @@ def test_unresolved_targets_soc_minima(add_battery_assets, db): "value": "0.9 MWh", # unreachable } ], + "state-of-charge": {"sensor": soc_sensor.id}, "prefer-charging-sooner": False, }, flex_context={ @@ -312,19 +323,22 @@ def test_unresolved_targets_soc_minima(add_battery_assets, db): unresolved_targets = scheduling_result.unresolved_targets assert ( - str(battery.id) in unresolved_targets + str(soc_sensor.id) in unresolved_targets ), "Expected an unresolved soc-minima since the target is unreachable" - assert "soc-minima" in unresolved_targets[str(battery.id)] - # The scheduled SoC should be below the 0.9 MWh target (delta == 260.0 kWh shortage) - assert unresolved_targets[str(battery.id)]["soc-minima"]["delta"] == "260.0 kWh" + assert "soc-minima" in unresolved_targets[str(soc_sensor.id)] + # The scheduled SoC should be below the 0.9 MWh target (unmet == 260.0 kWh shortage) + assert unresolved_targets[str(soc_sensor.id)]["soc-minima"]["unmet"] == "260.0 kWh" # The constraint is at 2015-01-02T00:00:00+01:00 = 2015-01-01T23:00:00+00:00 (UTC) assert ( - unresolved_targets[str(battery.id)]["soc-minima"]["datetime"] + unresolved_targets[str(soc_sensor.id)]["soc-minima"]["datetime"] == "2015-01-01T23:00:00+00:00" ) # No soc-maxima was set, so it should not appear - assert "soc-maxima" not in unresolved_targets[str(battery.id)] + assert "soc-maxima" not in unresolved_targets[str(soc_sensor.id)] + + # No soc-maxima constraint defined, so resolved_targets should be empty + assert scheduling_result.resolved_targets == {} def test_unresolved_targets_none_when_met(add_battery_assets, db): @@ -337,6 +351,15 @@ def test_unresolved_targets_none_when_met(add_battery_assets, db): _, battery = get_sensors_from_db( db, add_battery_assets, battery_name="Test battery" ) + soc_sensor = Sensor( + name="state-of-charge-none-when-met-test", + generic_asset=battery.generic_asset, + unit="MWh", + event_resolution=timedelta(0), + ) + db.session.add(soc_sensor) + db.session.flush() + tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -361,6 +384,7 @@ def test_unresolved_targets_none_when_met(add_battery_assets, db): "value": "0.5 MWh", # easily reachable } ], + "state-of-charge": {"sensor": soc_sensor.id}, "prefer-charging-sooner": False, }, flex_context={ @@ -377,10 +401,21 @@ def test_unresolved_targets_none_when_met(add_battery_assets, db): (r for r in results if r.get("name") == "scheduling_result"), None ) assert scheduling_result_entry is not None - unresolved_targets = scheduling_result_entry["data"].unresolved_targets + scheduling_result = scheduling_result_entry["data"] + unresolved_targets = scheduling_result.unresolved_targets # The minima target is met, so no unresolved targets expected assert unresolved_targets == {} + # The soc-minima was met, so resolved_targets should report it + assert str(soc_sensor.id) in scheduling_result.resolved_targets + assert "soc-minima" in scheduling_result.resolved_targets[str(soc_sensor.id)] + margin_str = scheduling_result.resolved_targets[str(soc_sensor.id)]["soc-minima"][ + "margin" + ] + # Margin should be a non-negative kWh string + assert margin_str.endswith(" kWh") + assert float(margin_str.replace(" kWh", "")) >= 0 + def test_unresolved_targets_soc_maxima(add_battery_assets, db): """Test that unresolved soc-maxima targets are reported in the scheduling result. @@ -396,6 +431,15 @@ def test_unresolved_targets_soc_maxima(add_battery_assets, db): _, battery = get_sensors_from_db( db, add_battery_assets, battery_name="Test battery" ) + soc_sensor = Sensor( + name="state-of-charge-maxima-test", + generic_asset=battery.generic_asset, + unit="MWh", + event_resolution=timedelta(0), + ) + db.session.add(soc_sensor) + db.session.flush() + tz = pytz.timezone("Europe/Amsterdam") start = tz.localize(datetime(2015, 1, 1)) end = tz.localize(datetime(2015, 1, 2)) @@ -420,6 +464,7 @@ def test_unresolved_targets_soc_maxima(add_battery_assets, db): "value": "0.5 MWh", # unreachably low } ], + "state-of-charge": {"sensor": soc_sensor.id}, "prefer-charging-sooner": False, }, flex_context={ @@ -439,16 +484,19 @@ def test_unresolved_targets_soc_maxima(add_battery_assets, db): unresolved_targets = scheduling_result_entry["data"].unresolved_targets assert ( - str(battery.id) in unresolved_targets + str(soc_sensor.id) in unresolved_targets ), "Expected an unresolved soc-maxima since the target is unreachable" - assert "soc-maxima" in unresolved_targets[str(battery.id)] - # The scheduled SoC should be above the 0.5 MWh target (delta == 160.0 kWh excess) - assert unresolved_targets[str(battery.id)]["soc-maxima"]["delta"] == "160.0 kWh" + assert "soc-maxima" in unresolved_targets[str(soc_sensor.id)] + # The scheduled SoC should be above the 0.5 MWh target (unmet == 160.0 kWh excess) + assert unresolved_targets[str(soc_sensor.id)]["soc-maxima"]["unmet"] == "160.0 kWh" # The constraint is at 2015-01-02T00:00:00+01:00 = 2015-01-01T23:00:00+00:00 (UTC) assert ( - unresolved_targets[str(battery.id)]["soc-maxima"]["datetime"] + unresolved_targets[str(soc_sensor.id)]["soc-maxima"]["datetime"] == "2015-01-01T23:00:00+00:00" ) # No soc-minima was set, so it should not appear - assert "soc-minima" not in unresolved_targets[str(battery.id)] + assert "soc-minima" not in unresolved_targets[str(soc_sensor.id)] + + # No soc-minima constraint defined, so resolved_targets should be empty + assert scheduling_result_entry["data"].resolved_targets == {} diff --git a/flexmeasures/data/services/scheduling_result.py b/flexmeasures/data/services/scheduling_result.py index 2f8a110952..03f8c5231d 100644 --- a/flexmeasures/data/services/scheduling_result.py +++ b/flexmeasures/data/services/scheduling_result.py @@ -17,35 +17,67 @@ class SchedulingJobResult: unresolved_targets: dict = field(default_factory=dict) """First unmet ``soc-minima`` and/or ``soc-maxima`` targets, per sensor. - The outer dict is keyed by sensor ID string (``str(sensor.id)``): the - state-of-charge sensor if the device has one, otherwise the power sensor. + The outer dict is keyed by state-of-charge sensor ID string (``str(sensor.id)``). Each value is a dict with constraint-type keys (``"soc-minima"`` and/or ``"soc-maxima"``), each mapping to: - ``"datetime"``: ISO 8601 UTC timestamp of the first violated constraint. - - ``"delta"``: Always-positive magnitude of the violation in kWh, + - ``"unmet"``: Always-positive magnitude of the violation in kWh, formatted as e.g. ``"260.0 kWh"``. For ``soc-minima`` this is the shortage (SoC fell short by this amount); for ``soc-maxima`` this is the excess (SoC exceeded the target by this amount). - An empty dict means all targets have been met. + An empty dict means all targets have been met (or no state-of-charge sensor is set). Example:: { "42": { - "soc-minima": {"datetime": "2024-01-01T10:00:00+00:00", "delta": "260.0 kWh"}, + "soc-minima": {"datetime": "2024-01-01T10:00:00+00:00", "unmet": "260.0 kWh"}, }, } Devices with no violations are absent from the outer dict. """ + resolved_targets: dict = field(default_factory=dict) + """Tightest met ``soc-minima`` and/or ``soc-maxima`` constraint per sensor. + + The outer dict is keyed by state-of-charge sensor ID string (``str(sensor.id)``). + Each value is a dict with constraint-type keys (``"soc-minima"`` and/or + ``"soc-maxima"``), each mapping to: + + - ``"datetime"``: ISO 8601 UTC timestamp of the constraint slot with the + smallest positive margin (i.e. the tightest constraint that was still met). + - ``"margin"``: Non-negative headroom in kWh, formatted as e.g. ``"40.0 kWh"``. + For ``soc-minima`` this is how far above the minimum the SoC was; + for ``soc-maxima`` this is how far below the maximum the SoC was. + + An empty dict means no constraints of that type were defined (or no + state-of-charge sensor is set). + + Example:: + + { + "42": { + "soc-maxima": {"datetime": "2024-01-01T12:00:00+00:00", "margin": "40.0 kWh"}, + }, + } + + Devices with no resolved targets are absent from the outer dict. + """ + def to_dict(self) -> dict: """Serialize to a JSON-compatible dict.""" - return {"unresolved_targets": self.unresolved_targets} + return { + "unresolved_targets": self.unresolved_targets, + "resolved_targets": self.resolved_targets, + } @classmethod def from_dict(cls, d: dict) -> "SchedulingJobResult": """Deserialize from a dict.""" - return cls(unresolved_targets=d.get("unresolved_targets", {})) + return cls( + unresolved_targets=d.get("unresolved_targets", {}), + resolved_targets=d.get("resolved_targets", {}), + ) From 68b90f60f84db3d8955d2d9e0164987a23c14665 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:33:50 +0000 Subject: [PATCH 09/11] scheduling: clarify margin invariant comments and improve SoC docs note Context: - Code review flagged potential ambiguity about margin sign in resolved_targets - Docs note lacked guidance on how to configure the state-of-charge sensor Change: - Add inline comments explaining that violations.empty guarantees margins >= 0 for both soc-minima and soc-maxima resolved branches - Expand the note in scheduling.rst to mention the flex model field syntax Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- documentation/features/scheduling.rst | 6 +++++- flexmeasures/data/models/planning/storage.py | 6 ++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index f6493ff44a..eaf7cd386b 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -348,7 +348,11 @@ The ``resolved_targets`` field lists soft SoC constraints that *were* satisfied, An empty ``{}`` means no constraints of that type were defined. -.. note:: Setting a ``state-of-charge`` sensor on the device is required to populate ``unresolved_targets`` and ``resolved_targets``. +.. note:: Setting a ``state-of-charge`` sensor on the device is required to populate + ``unresolved_targets`` and ``resolved_targets``. Configure it via the + ``state-of-charge`` field in the device's flex model, e.g. + ``"state-of-charge": {"sensor": }``. See the flex-model + documentation for the StorageScheduler for details. For full technical details of the response schema, refer to the API endpoint documentation for ``GET /sensors//schedules/``. diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 70c951ab6c..faec70d56e 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1470,7 +1470,8 @@ def _compute_unresolved_targets( "unmet": f"{unmet_kwh} kWh", } else: - # All minima met — record the tightest margin (min headroom above min) + # All minima met — record the tightest margin (min headroom above min). + # violations.empty guarantees shortages <= 0, so margins (soc - minima) >= 0. margins = aligned_soc - defined_minima tightest_t = margins.idxmin() margin_kwh = round(float(margins[tightest_t]) * 1000, precision) @@ -1504,7 +1505,8 @@ def _compute_unresolved_targets( "unmet": f"{unmet_kwh} kWh", } else: - # All maxima met — record the tightest margin (min headroom below max) + # All maxima met — record the tightest margin (min headroom below max). + # violations.empty guarantees excesses <= 0, so margins (maxima - soc) >= 0. margins = defined_maxima - aligned_soc tightest_t = margins.idxmin() margin_kwh = round(float(margins[tightest_t]) * 1000, precision) From 2a8ee636bf332d61b0229472b3f2c55439eed642 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:22:12 +0000 Subject: [PATCH 10/11] docs: update changelog entry to reflect current scheduling_result format (unmet/margin in kWh) Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/34e9c4eb-65c2-45d1-8a93-a6f159c4d0a3 Co-authored-by: Flix6x <30658763+Flix6x@users.noreply.github.com> --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 143ba4d6bb..6831734c40 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -19,7 +19,7 @@ New features * UI support for editing JSON attributes on sensors, assets and accounts [see `PR #2093 `_] * Show sensor attributes on sensor page, if not empty [see `PR #2015 `_] * Separate the ``StorageScheduler``'s tie-breaking preference for a full :abbr:`SoC (state of charge)` from its reported energy costs [see `PR #2023 `_] -* The schedule API endpoint now returns a ``scheduling_result`` field alongside ``scheduler_info``. For the first unmet ``soc-minima`` or ``soc-maxima`` constraint it reports the violation datetime and the signed delta (scheduled SoC minus target value). Note that ``soc-targets`` are hard constraints and are never reported here [see `PR #2072 `_] +* The schedule API endpoint now returns a ``scheduling_result`` field alongside ``scheduler_info``, reporting unresolved and resolved soft SoC constraints (keyed by state-of-charge sensor ID). Unresolved targets include the violation datetime and an always-positive ``unmet`` value in kWh (shortage for ``soc-minima``; excess for ``soc-maxima``). Resolved targets report the ``margin`` (smallest positive headroom in kWh). Note that ``soc-targets`` are hard constraints and are never reported here [see `PR #2072 `_] * Improve asset graph hover interaction with a vertical ruler across subcharts, while keeping hover dots for easier visual tracking [see `PR #2079 `_] * Improve asset audit log messages for JSON field edits (especially ``sensors_to_show`` and nested flex-config values) [see `PR #2055 `_] From fd022892ce11c02ec03daf643533355234d8cc84 Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Tue, 14 Apr 2026 13:48:32 +0200 Subject: [PATCH 11/11] AGENTS.md: learned to verify merge status with git log --left-right before claiming conflicts resolved Signed-off-by: F.N. Claessen --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index fd25672974..5de7682ce4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1272,6 +1272,13 @@ Track and document when the Lead: 5. **Quick Navigation** - Prominent links to critical sections - **Verification**: Lead must now answer "Am I working solo?" before ANY execution +**Specific lesson learned (2026-04 merge conflict resolution)**: +- **Session**: Merge conflict resolution for `copilot/compute-first-unmet-targets` +- **Failure**: Lead claimed merge conflicts were resolved without actually performing a merge. The branch was behind `origin/main` by 10+ commits but Lead ran `git status` (which showed "nothing to commit"), checked for `<<<` markers (there were none because no merge was attempted), ran 3 tests, replied "resolved in 640e79ea", and closed the session. +- **Root cause**: "Already up to date" / "nothing to commit" from `git status` was misread as "no conflicts to resolve". The correct check is `git log --left-right origin/main...HEAD` which would have shown `<` markers for commits on main not yet in the branch. +- **Fix**: When asked to "resolve merge conflicts", always check `git log --left-right origin/main...HEAD` first to determine if main has advanced beyond the last merge. If `<` markers exist, `origin/main` has commits the branch lacks — a fresh merge is needed. +- **Prevention**: Add to merge conflict checklist: "Check `git log --oneline origin/main...HEAD --left-right` before claiming conflicts resolved. If `<` markers exist, main has commits the branch lacks — merge is needed." + Update this file to prevent repeating the same mistakes. ## Session Close Checklist (MANDATORY)