Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ New features
* CLI support for adding/editing account attributes [see `PR #2242 <https://www.github.com/FlexMeasures/flexmeasures/pull/2242>`_]
* Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 <https://www.github.com/FlexMeasures/flexmeasures/pull/2244>`_]
* Extended ``GET /api/v3_0/jobs/<uuid>`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 <https://www.github.com/FlexMeasures/flexmeasures/pull/2072>`_]
* New ``soc-value-at-end`` flex-model field for storage devices: assigns a marginal value (fixed quantity or sensor reference) to energy left in storage at the end of the scheduling horizon, countering myopic depletion of the storage [see `PR #2310 <https://www.github.com/FlexMeasures/flexmeasures/pull/2310>`_]
* Extended the scheduling job ``result`` field with a ``num-beliefs`` field reporting the total number of beliefs (scheduled values) saved to the database [see `PR #2280 <https://www.github.com/FlexMeasures/flexmeasures/pull/2280>`_]
* Migrate the asset tree in the UI's Structure tab from Vega to ECharts, adding interactive pan/zoom navigation and refreshed node styling [see `PR #2025 <https://www.github.com/FlexMeasures/flexmeasures/pull/2025>`_]

Expand Down
3 changes: 3 additions & 0 deletions documentation/features/scheduling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ For more details on the possible formats for field values, see :ref:`variable_qu
* - ``soc-usage``
- |SOC_USAGE.example|
- .. include:: ../_autodoc/SOC_USAGE.rst
* - ``soc-value-at-end``
- |SOC_VALUE_AT_END.example|
- .. include:: ../_autodoc/SOC_VALUE_AT_END.rst
* - ``roundtrip-efficiency``
- |ROUNDTRIP_EFFICIENCY.example|
- .. include:: ../_autodoc/ROUNDTRIP_EFFICIENCY.rst
Expand Down
68 changes: 67 additions & 1 deletion flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@
FlexContextSchema,
MultiSensorFlexModelSchema,
)
from flexmeasures.data.schemas.sensors import SensorReference, VariableQuantityField
from flexmeasures.data.schemas.sensors import (
SensorReference,
VariableQuantityField,
PriceField,
)
from flexmeasures.data.services.scheduling_result import SchedulingJobResult
from flexmeasures.utils.calculations import (
integrate_time_series,
Expand Down Expand Up @@ -177,6 +181,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
soc_maxima = [None] * num_flexible_devices
soc_gain = [None] * num_flexible_devices
soc_usage = [None] * num_flexible_devices
soc_value_at_end = [None] * num_flexible_devices
prefer_charging_sooner = [None] * num_flexible_devices
prefer_curtailing_later = [None] * num_flexible_devices

Expand Down Expand Up @@ -210,6 +215,7 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901
soc_maxima[d0] = stock_model.get("soc_maxima")
soc_gain[d0] = stock_model.get("soc_gain")
soc_usage[d0] = stock_model.get("soc_usage")
soc_value_at_end[d0] = stock_model.get("soc_value_at_end")
prefer_charging_sooner[d0] = stock_model.get("prefer_charging_sooner")
prefer_curtailing_later[d0] = stock_model.get("prefer_curtailing_later")

Expand Down Expand Up @@ -834,6 +840,34 @@ def device_list_series(
# soc-maxima will become a soft constraint (modelled as stock commitments), so remove hard constraint
soc_maxima[d] = None

if soc_value_at_end[d] is not None and soc_at_start[d] is not None:
# Assign a marginal value to energy left in storage at the end of the
# planning window, to counter myopic depletion of the storage.
soc_value_at_end_d = get_continuous_series_sensor_or_quantity(
variable_quantity=soc_value_at_end[d],
unit=self.flex_context["shared_currency_unit"]
+ "/MWh*h", # from EUR/MWh² to EUR/MWh/resolution
query_window=(start + resolution, end + resolution),
resolution=resolution,
beliefs_before=belief_time,
fill_sides=True,
).shift(-1, freq=resolution)
# Only the state of charge at the end of the planning window is valued
soc_value_at_end_d.iloc[:-1] = 0

commitment = StockCommitment(
name="value of soc at end",
# baseline is an (absolute) zero state of charge, so the upwards
# deviation in the final time slot is the final state of charge
quantity=-soc_at_start[d] * (timedelta(hours=1) / resolution),
# negative prices reward a higher state of charge at the end
upwards_deviation_price=-soc_value_at_end_d,
downwards_deviation_price=-soc_value_at_end_d,
index=index,
device=d,
)
commitments.append(commitment)

# only apply SOC constraints to the first device of a shared stock
apply_soc_constraints = True

Expand Down Expand Up @@ -1288,6 +1322,7 @@ def deserialize_flex_config(self):
self.collect_flex_config()
self._deserialize_flex_context()
self._deserialize_flex_model()
self._validate_flex_model_price_units()

# Classify all flex-model entries (and the flex-context's inflexible devices)
# once; scheduling and result mapping rely on this inventory for device
Expand Down Expand Up @@ -1330,6 +1365,37 @@ def _deserialize_flex_context(self):
f"Unsupported type of flex-context: '{type(self.flex_context)}'"
)

def _validate_flex_model_price_units(self):
"""Check that price fields in the flex-model use the flex-context's shared currency.

The flex-context validates that all its price fields share one currency
(its ``shared_currency_unit``); here we hold the flex-model's price fields
(declared as PriceField) to that same currency, so that unit conversion
cannot fail later, when the scheduler runs.
"""
shared_currency_unit = self.flex_context.get("shared_currency_unit")
if shared_currency_unit is None:
return
flex_model = (
self.flex_model if isinstance(self.flex_model, list) else [self.flex_model]
)
for flex_model_d in flex_model:
for field_name, field in StorageFlexModelSchema._declared_fields.items():
if (
not isinstance(field, PriceField)
or flex_model_d.get(field_name) is None
):
continue
price_unit = field._get_unit(flex_model_d[field_name])
currency_unit = str(
(ur.Quantity(price_unit) / ur.Quantity(f"1{field.to_unit}")).units
)
if not units_are_convertible(currency_unit, shared_currency_unit):
raise ValidationError(
f"Invalid unit. A valid unit would be, for example, '{shared_currency_unit + field.to_unit}', because the flex-context uses '{shared_currency_unit}' as its currency. However, the '{field.data_key}' field in the flex-model uses an incompatible price unit ('{price_unit}').",
field_name=field.data_key,
)

def _deserialize_flex_model(self):
if isinstance(self.flex_model, dict):
if self.sensor.generic_asset.asset_type.name in storage_asset_types:
Expand Down
86 changes: 86 additions & 0 deletions flexmeasures/data/models/planning/tests/test_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,92 @@ def test_battery_solver_day_2(
)


@pytest.mark.parametrize(
"soc_value_at_end, expect_full_at_end",
[
("0 EUR/MWh", False),
("0.001 EUR/kWh", False),
("1000 EUR/MWh", True),
],
)
def test_battery_solver_day_2_with_soc_value_at_end(
setup_planning_test_data,
add_battery_assets,
soc_value_at_end: str,
expect_full_at_end: bool,
db,
):
"""Check that valuing the SoC at the end of the scheduling horizon counters myopic depletion.

Day 2 is set up with 8 expensive, then 8 cheap, then again 8 expensive hours.
Without (or with only a tiny) soc-value-at-end, the battery sells out towards the end of the horizon.
With a soc-value-at-end that exceeds the highest consumption price, the battery ends the horizon full instead.
"""
_epex_da, battery = get_sensors_from_db(db, add_battery_assets)
tz = pytz.timezone("Europe/Amsterdam")
start = tz.localize(datetime(2015, 1, 2))
end = tz.localize(datetime(2015, 1, 3))
resolution = timedelta(minutes=15)
soc_at_start = battery.get_attribute("soc_in_mwh")
soc_min = 0.5
soc_max = 4.5
scheduler = StorageScheduler(
battery,
start,
end,
resolution,
flex_model={
"soc-at-start": soc_at_start,
"soc-min": soc_min,
"soc-max": soc_max,
"roundtrip-efficiency": 1,
"storage-efficiency": 1,
"prefer-curtailing-later": False,
"soc-value-at-end": soc_value_at_end,
},
)
schedule = scheduler.compute()

# Check if constraints were met
soc_schedule = check_constraints(battery, schedule, soc_at_start)

if expect_full_at_end:
np.testing.assert_approx_equal(
soc_schedule.iloc[-1], soc_max, significant=3
) # The value of a full battery at the end beats the energy prices
else:
np.testing.assert_approx_equal(
soc_schedule.iloc[-1], soc_min, significant=3
) # Battery still sold out at the end of its planning horizon


def test_soc_value_at_end_currency_mismatch(
setup_planning_test_data,
add_battery_assets,
db,
):
"""A soc-value-at-end in a different currency than the flex-context's shared currency is rejected upon deserialization."""
from marshmallow import ValidationError

_epex_da, battery = get_sensors_from_db(db, add_battery_assets)
tz = pytz.timezone("Europe/Amsterdam")
start = tz.localize(datetime(2015, 1, 2))
end = tz.localize(datetime(2015, 1, 3))
resolution = timedelta(minutes=15)
scheduler = StorageScheduler(
battery,
start,
end,
resolution,
flex_model={
"soc-at-start": battery.get_attribute("soc_in_mwh"),
"soc-value-at-end": "1000 USD/MWh",
},
)
with pytest.raises(ValidationError, match="soc-value-at-end"):
scheduler.compute()


def run_test_charge_discharge_sign(
battery,
roundtrip_efficiency,
Expand Down
Loading
Loading