diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 5da39a5a6a..41b5bc12da 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -18,7 +18,7 @@ New features * Support forecasting from a given time in the past, by allowing to specify a ``prior`` belief time in the forecasting API endpoint (as already possible with CLI command) [see `PR #1978 `_] * 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 `_] +* Separate the ``StorageScheduler``'s tie-breaking preference for a full :abbr:`SoC (state of charge)` from its reported energy costs [see `PR #2023 `_ and `PR #2108 `_] * 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 `_] * Added a form on the UI for deleting sensor data sources [see `PR #2095 `_] diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index dc0549517d..d50227f5fb 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -438,7 +438,13 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 for d, (prefer_charging_sooner_d, prefer_curtailing_later_d) in enumerate( zip(prefer_charging_sooner, prefer_curtailing_later) ): - if prefer_charging_sooner_d: + # Mixed-device schedules can include non-storage devices such as PV. + # These do not have a state of charge, so there is nothing to "prefer full". + if ( + prefer_charging_sooner_d + and soc_max[d] is not None + and soc_at_start[d] is not None + ): tiny_price_slope = ( add_tiny_price_slope( up_deviation_prices, "event_value", order="desc" diff --git a/flexmeasures/data/models/planning/tests/conftest.py b/flexmeasures/data/models/planning/tests/conftest.py index 5c1aa380a5..d829f326cc 100644 --- a/flexmeasures/data/models/planning/tests/conftest.py +++ b/flexmeasures/data/models/planning/tests/conftest.py @@ -102,8 +102,7 @@ def building(db, setup_accounts, setup_markets) -> GenericAsset: select(GenericAssetType).filter_by(name="building") ).scalar_one_or_none() if not building_type: - # create_test_battery_assets might have created it already - building_type = GenericAssetType(name="battery") + building_type = GenericAssetType(name="building") db.session.add(building_type) building = GenericAsset( name="building", diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index d31aa7c82d..13a062ea29 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -2891,6 +2891,74 @@ def initialize_combined_commitments(num_devices: int): ), "Individual costs mismatch: Costs for one or more devices are not calculated as expected." +def test_prefer_full_storage_skips_non_storage_devices(db, building): + """Do not apply SoC-based storage preferences to non-storage devices such as PV.""" + + battery = Sensor( + name="mixed battery power sensor", + generic_asset=building, + event_resolution=timedelta(hours=1), + unit="MW", + ) + pv = Sensor( + name="mixed pv power sensor", + generic_asset=building, + event_resolution=timedelta(hours=1), + unit="MW", + attributes={"is_strictly_non_positive": True}, + ) + db.session.add_all([battery, pv]) + db.session.commit() + + start = pd.Timestamp("2020-01-01T00:00:00", tz="Europe/Amsterdam") + end = start + timedelta(hours=4) + resolution = timedelta(hours=1) + + scheduler = StorageScheduler( + asset_or_sensor=building, + start=start, + end=end, + resolution=resolution, + flex_model=[ + { + "sensor": battery, + "soc_at_start": 1.0, + "soc_min": 0.0, + "soc_max": 2.0, + "power_capacity_in_mw": ur.Quantity("1 MW"), + "consumption_capacity": ur.Quantity("1 MW"), + "production_capacity": ur.Quantity("1 MW"), + "prefer_charging_sooner": True, + "prefer_curtailing_later": True, + }, + { + "sensor": pv, + "power_capacity_in_mw": ur.Quantity("1 MW"), + "consumption_capacity": ur.Quantity("0 MW"), + "production_capacity": ur.Quantity("1 MW"), + "prefer_charging_sooner": True, + "prefer_curtailing_later": True, + }, + ], + flex_context={ + "consumption_price": ur.Quantity("100 EUR/MWh"), + "production_price": ur.Quantity("100 EUR/MWh"), + "shared_currency_unit": "EUR", + "ems_power_capacity_in_mw": ur.Quantity("2 MW"), + }, + return_multiple=True, + ) + scheduler.config_deserialized = True + + schedule = scheduler.compute() + + assert isinstance(schedule, list) + assert any( + result.get("name") == "storage_schedule" and result.get("sensor") == battery + for result in schedule + ) + + def test_multiple_devices_sequential_scheduler(): start = pd.Timestamp("2023-01-01T00:00:00") end = pd.Timestamp("2023-01-02T00:00:00")