Repository: FlexMeasures/flexmeasures
Pull request #996 (2024-02-29 21:16:16)

add SOC constraints as sensors
## Description

This PR introduces the possibility to define SOC Targets, SOC Maxima and SOC Minima as sensors. The existing way of defining those is still supported.

## TODO
- [x] Changelog entry
- [x] Add documentation to trigger schedule endpoint docstring.
- [x] Add CLI field to pass the SOC target as sensor. Currently, we don't support passing these as values, but we should support passing a sensor. A CLI changelog entry should follow. Moved to https://github.com/FlexMeasures/flexmeasures/issues/1002


Potential improvements to be done in the same PR or follow up Issues/Discussions/PRs.

## Related Items

https://github.com/FlexMeasures/flexmeasures/discussions/957

---

- [X] I agree to contribute to the project under Apache 2 License. 
- [X] To the best of my knowledge, the proposed patch is not based on code under GPL or other license that is incompatible with FlexMeasures


Flix6x: @victorgarcia98 Can you resolve the merge conflicts, please? I believe this could still be shipped.

Patch:
diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst
index 081e82ea..3f32d631 100644
--- a/documentation/api/change_log.rst
+++ b/documentation/api/change_log.rst
@@ -5,6 +5,12 @@ API change log
 
 .. note:: The FlexMeasures API follows its own versioning scheme. This is also reflected in the URL (e.g. `/api/v3_0`), allowing developers to upgrade at their own pace.
 
+
+v3.0-18 | 2024-03-07
+""""""""""""""""""""
+- Add support for providing a sensor definition to the ``soc-minima``, ``soc-maxima`` and ``soc-targets`` flex-model fields for `/sensors/<id>/schedules/trigger` (POST).
+
+
 v3.0-17 | 2024-02-26
 """"""""""""""""""""
 
diff --git a/documentation/changelog.rst b/documentation/changelog.rst
index b2c0ded4..3e43b6b3 100644
--- a/documentation/changelog.rst
+++ b/documentation/changelog.rst
@@ -14,6 +14,7 @@ New features
 
 * Add command ``flexmeasures edit transfer-ownership`` to transfer the ownership of an asset and its children from one account to another[see `PR #983 <https://github.com/FlexMeasures/flexmeasures/pull/983>`_]
 * Support defining the ``site-power-capacity``, ``site-consumption-capacity`` and ``site-production-capacity`` as a sensor in the API and CLI [see `PR #985 <https://github.com/FlexMeasures/flexmeasures/pull/985>`_]
+* Support defining the ``soc-minima``, ``soc-maxima`` and ``soc-targets`` as sensors in the API [see `PR #996 <https://github.com/FlexMeasures/flexmeasures/pull/996>`_]
 * Support defining inflexible power sensors with arbitrary power and energy units [see `PR #1007 <https://github.com/FlexMeasures/flexmeasures/pull/1007>`_]
 * Support saving beliefs with a ``belief_horizon`` in the ``PandasReporter``[see `PR #1013 <https://github.com/FlexMeasures/flexmeasures/pull/1013>`_]
 * Skip the check of the output event resolution in any ``Reporter`` with the field ``check_output_resolution`` [see `PR #1009 <https://github.com/FlexMeasures/flexmeasures/pull/1009>`_]
@@ -23,8 +24,6 @@ Bugfixes
 
 * Use minimum event resolution of the input (instead of the output) sensors for the belief search parameters [see `PR #1010 <https://github.com/FlexMeasures/flexmeasures/pull/1010>`_]
 
-
-
 Infrastructure / Support
 ----------------------
 
diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py
index df17f63e..8c2f01aa 100644
--- a/flexmeasures/api/v3_0/sensors.py
+++ b/flexmeasures/api/v3_0/sensors.py
@@ -290,8 +290,7 @@ class SensorAPI(FlaskView):
         The charging efficiency is constant (120%) and the discharging efficiency is determined by the contents of sensor
         with id 98. If just the ``roundtrip-efficiency`` is known, it can be described with its own field.
         The global minimum and maximum soc are set to 10 and 25 kWh, respectively.
-        To guarantee a minimum SOC in the period prior to 4.00pm, local minima constraints are imposed (via soc-minima)
-        at 2.00pm and 3.00pm, for 15kWh and 20kWh, respectively.
+        To guarantee a minimum SOC in the period prior, the sensor with ID 300 contains beliefs at 2.00pm and 3.00pm, for 15kWh and 20kWh, respectively.
         Storage efficiency is set to 99.99%, denoting the state of charge left after each time step equal to the sensor's resolution.
         Aggregate consumption (of all devices within this EMS) should be priced by sensor 9,
         and aggregate production should be priced by sensor 10,
@@ -318,13 +317,7 @@ class SensorAPI(FlaskView):
                             "datetime": "2015-06-02T16:00:00+00:00"
                         },
                     ],
-                    "soc-minima": [
-                        {
-                            "value": 15,
-                            "start": "2015-06-02T14:00:00+00:00",
-                            "end": "2015-06-02T15:00:00+00:00"
-                        }
-                    ],
+                    "soc-minima": {"sensor" : 300},
                     "soc-min": 10,
                     "soc-max": 25,
                     "charging-efficiency": "120%",
diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py
index 76976c53..0e05cac1 100644
--- a/flexmeasures/data/models/planning/storage.py
+++ b/flexmeasures/data/models/planning/storage.py
@@ -173,6 +173,41 @@ class MetaStorageScheduler(Scheduler):
                 sensor=inflexible_sensor,
             )
 
+        # fetch SOC constraints from sensors
+        if isinstance(soc_targets, Sensor):
+            soc_targets = get_continuous_series_sensor_or_quantity(
+                quantity_or_sensor=soc_targets,
+                actuator=sensor,
+                unit="MWh",
+                query_window=(start, end),
+                resolution=resolution,
+                beliefs_before=belief_time,
+                as_instantaneous_events=True,
+                boundary_policy="first",
+            )
+        if isinstance(soc_minima, Sensor):
+            soc_minima = get_continuous_series_sensor_or_quantity(
+                quantity_or_sensor=soc_minima,
+                actuator=sensor,
+                unit="MWh",
+                query_window=(start, end),
+                resolution=resolution,
+                beliefs_before=belief_time,
+                as_instantaneous_events=True,
+                boundary_policy="max",
+            )
+        if isinstance(soc_maxima, Sensor):
+            soc_maxima = get_continuous_series_sensor_or_quantity(
+                quantity_or_sensor=soc_maxima,
+                actuator=sensor,
+                unit="MWh",
+                query_window=(start, end),
+                resolution=resolution,
+                beliefs_before=belief_time,
+                as_instantaneous_events=True,
+                boundary_policy="min",
+            )
+
         device_constraints[0] = add_storage_constraints(
             start,
             end,
@@ -451,7 +486,8 @@ class MetaStorageScheduler(Scheduler):
               this function would become a class method with a @post_load decorator.
         """
         soc_targets = self.flex_model.get("soc_targets")
-        if soc_targets:
+
+        if soc_targets and not isinstance(soc_targets, Sensor):
             max_target_datetime = max([soc_target["end"] for soc_target in soc_targets])
             if max_target_datetime > self.end:
                 max_server_horizon = get_max_planning_horizon(self.resolution)
@@ -466,6 +502,11 @@ class MetaStorageScheduler(Scheduler):
         min_target = None
         max_target = None
         soc_targets_label = "soc_targets" if deserialized_names else "soc-targets"
+
+        # if the SOC targets are defined as a Sensor, we don't get min max values
+        if isinstance(self.flex_model.get(soc_targets_label), dict):
+            return None, None
+
         if (
             soc_targets_label in self.flex_model
             and len(self.flex_model[soc_targets_label]) > 0
@@ -650,16 +691,17 @@ def build_device_soc_values(
 
     Should set NaN anywhere where there is no target.
 
-    SOC values should be indexed by their due date. For example, for quarter-hourly targets between 5 and 6 AM:
-    >>> df = pd.Series(data=[1, 2, 2.5, 3], index=pd.date_range(datetime(2010,1,1,5), datetime(2010,1,1,6), freq=timedelta(minutes=15), inclusive="right"))
+    SOC values should be indexed by their due date. For example, for quarter-hourly targets from 5 to 6 AM:
+    >>> df = pd.Series(data=[1, 1.5, 2, 2.5, 3], index=pd.date_range(pd.Timestamp("2010-01-01T05"), pd.Timestamp("2010-01-01T06"), freq=pd.Timedelta("PT15M"), inclusive="both"))
     >>> print(df)
-        2010-01-01 05:15:00    1.0
-        2010-01-01 05:30:00    2.0
-        2010-01-01 05:45:00    2.5
-        2010-01-01 06:00:00    3.0
-        Freq: 15T, dtype: float64
-
-    TODO: this function could become the deserialization method of a new SOCValueSchema (targets, plural), which wraps SOCValueSchema.
+    2010-01-01 05:00:00    1.0
+    2010-01-01 05:15:00    1.5
+    2010-01-01 05:30:00    2.0
+    2010-01-01 05:45:00    2.5
+    2010-01-01 06:00:00    3.0
+    Freq: 15T, dtype: float64
+
+    TODO: this function could become the deserialization method of a new TimedEventSchema (targets, plural), which wraps TimedEventSchema.
 
     """
     if isinstance(soc_values, pd.Series):  # some tests prepare it this way
diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py
index 3c0aedc9..908cf65e 100644
--- a/flexmeasures/data/models/planning/utils.py
+++ b/flexmeasures/data/models/planning/utils.py
@@ -331,16 +331,22 @@ def get_series_from_quantity_or_sensor(
     query_window: tuple[datetime, datetime],
     resolution: timedelta,
     beliefs_before: datetime | None = None,
+    as_instantaneous_events: bool = True,
+    boundary_policy: str | None = None,
 ) -> pd.Series:
     """
     Get a time series given a quantity or sensor defined on a time window.
 
-    :param quantity_or_sensor:  pint Quantity or timely-beliefs Sensor, measuring e.g. power capacity or efficiency
-    :param unit:                unit of the output data.
-    :param query_window:        tuple representing the start and end of the requested data
-    :param resolution:          time resolution of the requested data
-    :param beliefs_before:      optional datetime used to indicate we are interested in the state of knowledge at that time
-    :return:                    pandas Series with the requested time series data
+    :param quantity_or_sensor:      A pint Quantity or timely-beliefs Sensor, measuring e.g. power capacity
+                                    or efficiency.
+    :param unit:                    Unit of the output data.
+    :param query_window:            Tuple representing the start and end of the requested data.
+    :param resolution:              Time resolution of the requested data.
+    :param beliefs_before:          Optional datetime used to indicate we are interested in the state of knowledge
+                                    at that time.
+    :param as_instantaneous_events: Optionally, convert to instantaneous events, in which case the passed resolution is
+                                    interpreted as the desired frequency of the data.
+    :return:                        Pandas Series with the requested time series data.
     """
 
     start, end = query_window
@@ -358,12 +364,16 @@ def get_series_from_quantity_or_sensor(
             event_starts_after=query_window[0],
             event_ends_before=query_window[1],
             resolution=resolution,
+            # frequency=resolution,
             beliefs_before=beliefs_before,
             most_recent_beliefs_only=True,
             one_deterministic_belief_per_event=True,
         )
+        if as_instantaneous_events:
+            bdf = bdf.resample_events(timedelta(0), boundary_policy=boundary_policy)
         time_series = simplify_index(bdf).reindex(index).squeeze()
         time_series = convert_units(time_series, quantity_or_sensor.unit, unit)
+
     else:
         raise TypeError(
             f"quantity_or_sensor {quantity_or_sensor} should be a pint Quantity or timely-beliefs Sensor"
@@ -381,6 +391,8 @@ def get_continuous_series_sensor_or_quantity(
     beliefs_before: datetime | None = None,
     fallback_attribute: str | None = None,
     max_value: float | int | pd.Series = np.nan,
+    as_instantaneous_events: bool = False,
+    boundary_policy: str | None = None,
 ) -> pd.Series:
     """Creates a time series from a quantity or sensor within a specified window,
     falling back to a given `fallback_attribute` and making sure no values exceed `max_value`.
@@ -393,6 +405,8 @@ def get_continuous_series_sensor_or_quantity(
     :param beliefs_before:          Timestamp for prior beliefs or knowledge.
     :param fallback_attribute:      Attribute serving as a fallback default in case no quantity or sensor is given.
     :param max_value:               Maximum value (also replacing NaN values).
+    :param as_instantaneous_events: optionally, convert to instantaneous events, in which case the passed resolution is
+                                    interpreted as the desired frequency of the data.
     :returns:                       time series data with missing values handled based on the chosen method.
     """
     if quantity_or_sensor is None:
@@ -408,6 +422,8 @@ def get_continuous_series_sensor_or_quantity(
         query_window=query_window,
         resolution=resolution,
         beliefs_before=beliefs_before,
+        as_instantaneous_events=as_instantaneous_events,
+        boundary_policy=boundary_policy,
     )
 
     # Apply upper limit
diff --git a/flexmeasures/data/models/reporting/pandas_reporter.py b/flexmeasures/data/models/reporting/pandas_reporter.py
index 0f091000..3f4afb20 100644
--- a/flexmeasures/data/models/reporting/pandas_reporter.py
+++ b/flexmeasures/data/models/reporting/pandas_reporter.py
@@ -168,7 +168,6 @@ class PandasReporter(Reporter):
         # filing the missing indexes with default values:
         if "belief_time" not in bdf.index.names:
             if belief_horizon is not None:
-
                 # In case that all the index but `event_start` are dropped
                 if (
                     isinstance(bdf.index, pd.DatetimeIndex)
@@ -179,7 +178,6 @@ class PandasReporter(Reporter):
                     event_start = bdf.index.get_event_values("event_start")
 
                 belief_time = event_start + bdf.event_resolution - belief_horizon
-
             else:
                 belief_time = [belief_time] * len(bdf)
             bdf["belief_time"] = belief_time
diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py
index bb8f18fd..93fda4c2 100644
--- a/flexmeasures/data/schemas/scheduling/storage.py
+++ b/flexmeasures/data/schemas/scheduling/storage.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 from datetime import datetime
 
-import isodate
 from flask import current_app
 from marshmallow import (
     Schema,
@@ -12,13 +11,11 @@ from marshmallow import (
     fields,
     validates,
 )
-from marshmallow.validate import OneOf, ValidationError, Validator
-import pandas as pd
+from marshmallow.validate import OneOf, ValidationError
 
 from flexmeasures.data.models.time_series import Sensor
-from flexmeasures.data.schemas.times import AwareDateTimeField, DurationField
 from flexmeasures.data.schemas.units import QuantityField
-from flexmeasures.data.schemas.sensors import QuantityOrSensor
+from flexmeasures.data.schemas.sensors import QuantityOrSensor, TimeSeriesOrSensor
 
 from flexmeasures.utils.unit_utils import ur
 
@@ -50,84 +47,6 @@ class EfficiencyField(QuantityField):
         )
 
 
-class SOCValueSchema(Schema):
-    value = fields.Float(required=True)
-    datetime = AwareDateTimeField(required=False)
-    start = AwareDateTimeField(required=False)
-    end = AwareDateTimeField(required=False)
-    duration = DurationField(required=False)
-
-    def __init__(
-        self,
-        timezone: str | None = None,
-        value_validator: Validator | None = None,
-        *args,
-        **kwargs,
-    ):
-        """A time period (or single point) with a target value.
-
-        :param timezone:  Optionally, set a timezone to be able to interpret nominal durations.
-        """
-        self.timezone = timezone
-        self.value_validator = value_validator
-        super().__init__(*args, **kwargs)
-
-    @validates("value")
-    def validate_value(self, _value):
-        if self.value_validator is not None:
-            self.value_validator(_value)
-
-    @validates_schema
-    def check_time_window(self, data: dict, **kwargs):
-        """Checks whether a complete time interval can be derived from the timing fields.
-
-        The data is updated in-place, guaranteeing that the 'start' and 'end' fields are filled out.
-        """
-        dt = data.get("datetime")
-        start = data.get("start")
-        end = data.get("end")
-        duration = data.get("duration")
-
-        if dt is not None:
-            if any([p is not None for p in (start, end, duration)]):
-                raise ValidationError(
-                    "If using the 'datetime' field, no 'start', 'end' or 'duration' is expected."
-                )
-            data["start"] = dt
-            data["end"] = dt
-        elif duration is not None:
-            if self.timezone is None and isinstance(duration, isodate.Duration):
-                raise ValidationError(
-                    "Cannot interpret nominal duration used in the 'duration' field without a known timezone."
-                )
-            elif all([p is None for p in (start, end)]) or all(
-                [p is not None for p in (start, end)]
-            ):
-                raise ValidationError(
-                    "If using the 'duration' field, either 'start' or 'end' is expected."
-                )
-            if start is not None:
-                grounded = DurationField.ground_from(
-                    duration, pd.Timestamp(start).tz_convert(self.timezone)
-                )
-                data["start"] = start
-                data["end"] = start + grounded
-            else:
-                grounded = DurationField.ground_from(
-                    -duration, pd.Timestamp(end).tz_convert(self.timezone)
-                )
-                data["start"] = end + grounded
-                data["end"] = end
-        else:
-            if any([p is None for p in (start, end)]):
-                raise ValidationError(
-                    "Missing field(s) to describe timing: use the 'datetime' field, "
-                    "or a combination of 2 fields of 'start', 'end' and 'duration'."
-                )
-            data["start"] = start
-            data["end"] = end
-
-
 class StorageFlexModelSchema(Schema):
     """
     This schema lists fields we require when scheduling storage assets.
@@ -151,22 +70,20 @@ class StorageFlexModelSchema(Schema):
         "MW", data_key="production-capacity", required=False
     )
 
-    # Timezone placeholder is overridden in __init__
-    soc_maxima = fields.List(
-        fields.Nested(SOCValueSchema(timezone="placeholder")),
-        data_key="soc-maxima",
+    # Timezone placeholder for the soc_maxima, soc_minima and soc_targets fields are overridden in __init__
+    soc_maxima = TimeSeriesOrSensor(
+        unit="MWh", timezone="placeholder", data_key="soc-maxima"
     )
-    soc_minima = fields.List(
-        fields.Nested(
-            SOCValueSchema(
-                timezone="placeholder", value_validator=validate.Range(min=0)
-            )
-        ),
+
+    soc_minima = TimeSeriesOrSensor(
+        unit="MWh",
+        timezone="placeholder",
         data_key="soc-minima",
+        value_validator=validate.Range(min=0),
     )
-    soc_targets = fields.List(
-        fields.Nested(SOCValueSchema(timezone="placeholder")),
-        data_key="soc-targets",
+
+    soc_targets = TimeSeriesOrSensor(
+        unit="MWh", timezone="placeholder", data_key="soc-targets"
     )
 
     soc_unit = fields.Str(
@@ -204,28 +121,29 @@ class StorageFlexModelSchema(Schema):
         """Pass the schedule's start, so we can use it to validate soc-target datetimes."""
         self.start = start
         self.sensor = sensor
-        self.soc_maxima = fields.List(
-            fields.Nested(SOCValueSchema(timezone=sensor.timezone)),
-            data_key="soc-maxima",
+        self.soc_maxima = TimeSeriesOrSensor(
+            unit="MWh", timezone=sensor.timezone, data_key="soc-maxima"
         )
-        self.soc_minima = fields.List(
-            fields.Nested(
-                SOCValueSchema(
-                    timezone=sensor.timezone, value_validator=validate.Range(min=0)
-                )
-            ),
+
+        self.soc_minima = TimeSeriesOrSensor(
+            unit="MWh",
+            timezone=sensor.timezone,
             data_key="soc-minima",
+            value_validator=validate.Range(min=0),
         )
-        self.soc_targets = fields.List(
-            fields.Nested(SOCValueSchema(timezone=sensor.timezone)),
-            data_key="soc-targets",
+        self.soc_targets = TimeSeriesOrSensor(
+            unit="MWh", timezone=sensor.timezone, data_key="soc-targets"
         )
+
         super().__init__(*args, **kwargs)
 
     @validates_schema
     def check_whether_targets_exceed_max_planning_horizon(self, data: dict, **kwargs):
-        soc_targets: list[dict[str, datetime | float]] | None = data.get("soc_targets")
-        if not soc_targets:
+        soc_targets: list[dict[str, datetime | float] | Sensor] | None = data.get(
+            "soc_targets"
+        )
+        # skip check if the SOC targets are not provided or if they are defined as sensors
+        if not soc_targets or isinstance(soc_targets, Sensor):
             return
         max_server_horizon = current_app.config.get("FLEXMEASURES_MAX_PLANNING_HORIZON")
         if isinstance(max_server_horizon, int):
diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py
index 70efff73..d344beb3 100644
--- a/flexmeasures/data/schemas/sensors.py
+++ b/flexmeasures/data/schemas/sensors.py
@@ -1,9 +1,19 @@
 from __future__ import annotations
-from marshmallow import Schema, fields, validate, validates, ValidationError
+from marshmallow import (
+    Schema,
+    fields,
+    validate,
+    validates,
+    ValidationError,
+    validates_schema,
+)
+from marshmallow.validate import Validator
 from pint import DimensionalityError
 
 import json
 import re
+import isodate
+import pandas as pd
 
 from flexmeasures.data import ma, db
 from flexmeasures.data.models.generic_assets import GenericAsset
@@ -14,7 +24,7 @@ from flexmeasures.data.schemas.utils import (
     with_appcontext_if_needed,
 )
 from flexmeasures.utils.unit_utils import is_valid_unit, ur, units_are_convertible
-from flexmeasures.data.schemas.times import DurationField
+from flexmeasures.data.schemas.times import DurationField, AwareDateTimeField
 
 
 class JSON(fields.Field):
@@ -28,6 +38,84 @@ class JSON(fields.Field):
         return json.dumps(value)
 
 
+class TimedEventSchema(Schema):
+    value = fields.Float(required=True)
+    datetime = AwareDateTimeField(required=False)
+    start = AwareDateTimeField(required=False)
+    end = AwareDateTimeField(required=False)
+    duration = DurationField(required=False)
+
+    def __init__(
+        self,
+        timezone: str | None = None,
+        value_validator: Validator | None = None,
+        *args,
+        **kwargs,
+    ):
+        """A time period (or single point) with a value.
+
+        :param timezone:  Optionally, set a timezone to be able to interpret nominal durations.
+        """
+        self.timezone = timezone
+        self.value_validator = value_validator
+        super().__init__(*args, **kwargs)
+
+    @validates("value")
+    def validate_value(self, _value):
+        if self.value_validator is not None:
+            self.value_validator(_value)
+
+    @validates_schema
+    def check_time_window(self, data: dict, **kwargs):
+        """Checks whether a complete time interval can be derived from the timing fields.
+
+        The data is updated in-place, guaranteeing that the 'start' and 'end' fields are filled out.
+        """
+        dt = data.get("datetime")
+        start = data.get("start")
+        end = data.get("end")
+        duration = data.get("duration")
+
+        if dt is not None:
+            if any([p is not None for p in (start, end, duration)]):
+                raise ValidationError(
+                    "If using the 'datetime' field, no 'start', 'end' or 'duration' is expected."
+                )
+            data["start"] = dt
+            data["end"] = dt
+        elif duration is not None:
+            if self.timezone is None and isinstance(duration, isodate.Duration):
+                raise ValidationError(
+                    "Cannot interpret nominal duration used in the 'duration' field without a known timezone."
+                )
+            elif all([p is None for p in (start, end)]) or all(
+                [p is not None for p in (start, end)]
+            ):
+                raise ValidationError(
+                    "If using the 'duration' field, either 'start' or 'end' is expected."
+                )
+            if start is not None:
+                grounded = DurationField.ground_from(
+                    duration, pd.Timestamp(start).tz_convert(self.timezone)
+                )
+                data["start"] = start
+                data["end"] = start + grounded
+            else:
+                grounded = DurationField.ground_from(
+                    -duration, pd.Timestamp(end).tz_convert(self.timezone)
+                )
+                data["start"] = end + grounded
+                data["end"] = end
+        else:
+            if any([p is None for p in (start, end)]):
+                raise ValidationError(
+                    "Missing field(s) to describe timing: use the 'datetime' field, "
+                    "or a combination of 2 fields of 'start', 'end' and 'duration'."
+                )
+            data["start"] = start
+            data["end"] = end
+
+
 class SensorSchemaMixin(Schema):
     """
     Base sensor schema.
@@ -78,15 +166,35 @@ class SensorSchema(SensorSchemaMixin, ma.SQLAlchemySchema):
 class SensorIdField(MarshmallowClickMixin, fields.Int):
     """Field that deserializes to a Sensor and serializes back to an integer."""
 
+    def __init__(self, *args, unit: str | ur.Quantity | None = None, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if isinstance(unit, str):
+            self.to_unit = ur.Quantity(unit)
+        elif isinstance(unit, ur.Quantity):
+            self.to_unit = unit
+        else:
+            self.to_unit = None
+
     @with_appcontext_if_needed()
     def _deserialize(self, value: int, attr, obj, **kwargs) -> Sensor:
         """Turn a sensor id into a Sensor."""
         sensor = db.session.get(Sensor, value)
         if sensor is None:
             raise FMValidationError(f"No sensor found with id {value}.")
+
         # lazy loading now (sensor is somehow not in session after this)
         sensor.generic_asset
         sensor.generic_asset.generic_asset_type
+
+        # if the units are defined, check if the sensor data is convertible to the target units
+        if self.to_unit is not None and not units_are_convertible(
+            sensor.unit, str(self.to_unit.units)
+        ):
+            raise FMValidationError(
+                f"Cannot convert {sensor.unit} to {self.to_unit.units}"
+            )
+
         return sensor
 
     def _serialize(self, sensor: Sensor, attr, data, **kwargs) -> int:
@@ -126,20 +234,9 @@ class QuantityOrSensor(MarshmallowClickMixin, fields.Field):
                 raise FMValidationError(
                     "Dictionary provided but `sensor` key not found."
                 )
-
-            sensor = db.session.get(Sensor, value["sensor"])
-
-            if sensor is None:
-                raise FMValidationError(f"No sensor found with id {value['sensor']}.")
-
-            # lazy loading now (sensor is somehow not in session after this)
-            sensor.generic_asset
-            sensor.generic_asset.generic_asset_type
-
-            if not units_are_convertible(sensor.unit, str(self.to_unit.units)):
-                raise FMValidationError(
-                    f"Cannot convert {sensor.unit} to {self.to_unit.units}"
-                )
+            sensor = SensorIdField(unit=self.to_unit)._deserialize(
+                value["sensor"], None, None
+            )
 
             return sensor
 
@@ -187,6 +284,57 @@ class QuantityOrSensor(MarshmallowClickMixin, fields.Field):
         return super().convert(_value, param, ctx, **kwargs)
 
 
+class TimeSeriesOrSensor(MarshmallowClickMixin, fields.Field):
+    def __init__(
+        self,
+        unit,
+        *args,
+        timezone: str | None = None,
+        value_validator: Validator | None = None,
+        **kwargs,
+    ):
+        """
+        The timezone is only used in case a time series is specified and one
+        of the *timed events* in the time series uses a nominal duration, such as "P1D".
+        """
+        super().__init__(*args, **kwargs)
+        self.timezone = timezone
+        self.value_validator = value_validator
+        self.unit = ur.Quantity(unit)
+
+    @with_appcontext_if_needed()
+    def _deserialize(
+        self, value: str | dict[str, int], attr, obj, **kwargs
+    ) -> list[dict] | Sensor:
+
+        if isinstance(value, dict):
+            if "sensor" not in value:
+                raise FMValidationError(
+                    "Dictionary provided but `sensor` key not found."
+                )
+
+            sensor = SensorIdField(unit=self.unit)._deserialize(
+                value["sensor"], None, None
+            )
+
+            return sensor
+
+        elif isinstance(value, list):
+            field = fields.List(
+                fields.Nested(
+                    TimedEventSchema(
+                        timezone=self.timezone, value_validator=self.value_validator
+                    )
+                )
+            )
+
+            return field._deserialize(value, None, None)
+        else:
+            raise FMValidationError(
+                f"Unsupported value type. `{type(value)}` was provided but only dict and list are supported."
+            )
+
+
 class RepurposeValidatorToIgnoreSensors(validate.Validator):
     """Validator that executes another validator (the one you initialize it with) only on non-Sensor values."""
 
diff --git a/requirements/3.10/app.txt b/requirements/3.10/app.txt
index c5739a2f..bcfec4eb 100644
--- a/requirements/3.10/app.txt
+++ b/requirements/3.10/app.txt
@@ -323,7 +323,7 @@ tabulate==0.9.0
     # via -r requirements/app.in
 threadpoolctl==3.2.0
     # via scikit-learn
-timely-beliefs[forecast]==2.1.0
+timely-beliefs[forecast]==2.2.0
     # via -r requirements/app.in
 timetomodel==0.7.3
     # via -r requirements/app.in
diff --git a/requirements/3.11/app.txt b/requirements/3.11/app.txt
index 8d3a2d64..65dedba2 100644
--- a/requirements/3.11/app.txt
+++ b/requirements/3.11/app.txt
@@ -321,7 +321,7 @@ tabulate==0.9.0
     # via -r requirements/app.in
 threadpoolctl==3.2.0
     # via scikit-learn
-timely-beliefs[forecast]==2.1.0
+timely-beliefs[forecast]==2.2.0
     # via -r requirements/app.in
 timetomodel==0.7.3
     # via -r requirements/app.in
diff --git a/requirements/3.8/app.txt b/requirements/3.8/app.txt
index b7577489..cedc6dd1 100644
--- a/requirements/3.8/app.txt
+++ b/requirements/3.8/app.txt
@@ -336,7 +336,7 @@ tabulate==0.9.0
     # via -r requirements/app.in
 threadpoolctl==3.2.0
     # via scikit-learn
-timely-beliefs[forecast]==2.1.0
+timely-beliefs[forecast]==2.2.0
     # via -r requirements/app.in
 timetomodel==0.7.3
     # via -r requirements/app.in
diff --git a/requirements/3.9/app.txt b/requirements/3.9/app.txt
index 4bbb4da8..3e1ee3ec 100644
--- a/requirements/3.9/app.txt
+++ b/requirements/3.9/app.txt
@@ -326,7 +326,7 @@ tabulate==0.9.0
     # via -r requirements/app.in
 threadpoolctl==3.2.0
     # via scikit-learn
-timely-beliefs[forecast]==2.1.0
+timely-beliefs[forecast]==2.2.0
     # via -r requirements/app.in
 timetomodel==0.7.3
     # via -r requirements/app.in
diff --git a/requirements/app.in b/requirements/app.in
index 0e47aedd..75798d39 100644
--- a/requirements/app.in
+++ b/requirements/app.in
@@ -35,7 +35,7 @@ pyomo>=5.6
 tabulate
 timetomodel>=0.7.3
 # significantly faster db queries
-timely-beliefs[forecast]>=2.1
+timely-beliefs[forecast]>=2.2
 python-dotenv
 # a backport, not needed in Python3.8
 importlib_metadata


Test patch:
diff --git a/flexmeasures/data/models/planning/tests/conftest.py b/flexmeasures/data/models/planning/tests/conftest.py
index 7e515df7..ea7f902d 100644
--- a/flexmeasures/data/models/planning/tests/conftest.py
+++ b/flexmeasures/data/models/planning/tests/conftest.py
@@ -368,6 +368,51 @@ def add_storage_efficiency(db, add_battery_assets, setup_sources) -> dict[str, S
     return sensors
 
 
+@pytest.fixture(scope="module")
+def add_soc_targets(db, add_battery_assets, setup_sources) -> dict[str, Sensor]:
+    """
+    Fixture to add storage SOC targets as sensors and their beliefs to the database.
+
+    The function creates a single event at 14:00 + offset with a value of 0.5.
+    """
+
+    battery = add_battery_assets["Test battery"]
+    soc_value = 0.5
+    soc_datetime = pd.Timestamp("2015-01-01T14:00:00", tz="Europe/Amsterdam")
+    sensors = {}
+
+    sensor_specs = [
+        # name, resolution, offset from the resolution tick
+        ("soc-targets (1h)", timedelta(minutes=60), timedelta(minutes=0)),
+        ("soc-targets (15min)", timedelta(minutes=15), timedelta(minutes=0)),
+        ("soc-targets (15min lagged)", timedelta(minutes=15), timedelta(minutes=5)),
+        ("soc-targets (5min)", timedelta(minutes=5), timedelta(minutes=0)),
+        ("soc-targets (instantaneous)", timedelta(minutes=0), timedelta(minutes=0)),
+    ]
+
+    for name, resolution, offset in sensor_specs:
+        storage_constraint_sensor = Sensor(
+            name=name,
+            unit="MWh",
+            event_resolution=resolution,
+            generic_asset=battery,
+        )
+        db.session.add(storage_constraint_sensor)
+        db.session.flush()
+
+        belief = TimedBelief(
+            event_start=soc_datetime + offset,
+            belief_horizon=timedelta(hours=100),
+            event_value=soc_value,
+            source=setup_sources["Seita"],
+            sensor=storage_constraint_sensor,
+        )
+        db.session.add(belief)
+        sensors[name] = storage_constraint_sensor
+
+    return sensors
+
+
 def add_as_beliefs(db, sensor, values, time_slots, source):
     beliefs = [
         TimedBelief(
diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py
index 53358d2e..edc3fa56 100644
--- a/flexmeasures/data/models/planning/tests/test_solver.py
+++ b/flexmeasures/data/models/planning/tests/test_solver.py
@@ -20,7 +20,7 @@ from flexmeasures.data.models.planning.storage import (
 from flexmeasures.data.models.planning.linear_optimization import device_scheduler
 from flexmeasures.data.models.planning.tests.utils import check_constraints
 from flexmeasures.data.models.planning.utils import initialize_series, initialize_df
-from flexmeasures.data.schemas.scheduling.storage import SOCValueSchema
+from flexmeasures.data.schemas.sensors import TimedEventSchema
 from flexmeasures.utils.calculations import (
     apply_stock_changes_and_losses,
     integrate_time_series,
@@ -1314,7 +1314,7 @@ def test_build_device_soc_values(caplog, soc_values, log_message, expected_num_t
 
     # Convert SoC datetimes to periods with a start and end.
     for soc in soc_values:
-        SOCValueSchema().check_time_window(soc)
+        TimedEventSchema().check_time_window(soc)
 
     with caplog.at_level(logging.WARNING):
         device_values = build_device_soc_values(
@@ -1870,3 +1870,101 @@ def test_battery_storage_efficiency_sensor(
 
     scheduler_info = scheduler._prepare()
     assert all(scheduler_info[5][0]["efficiency"] == expected_efficiency)
+
+
+@pytest.mark.parametrize(
+    "sensor_name, expected_start, expected_end",
+    [
+        # A value defined in a coarser resolution is upsampled to match the power sensor resolution.
+        (
+            "soc-targets (1h)",
+            "14:00:00",
+            "15:00:00",
+        ),
+        # A value defined in a finer resolution is downsampled to match the power sensor resolution.
+        # Only a single value coincides with the power sensor resolution.
+        pytest.param(
+            "soc-targets (5min)",
+            "14:00:00",
+            "14:00:00",  # not "14:15:00"
+            marks=pytest.mark.xfail(
+                reason="timely-beliefs doesn't yet make it possible to resample to a certain frequency and event resolution simultaneously"
+            ),
+        ),
+        # A simple case, SOC constraint sensor in the same resolution as the power sensor.
+        (
+            "soc-targets (15min)",
+            "14:00:00",
+            "14:15:00",
+        ),
+        # For an instantaneous sensor, the value is set to the interval containing the instantaneous event.
+        (
+            "soc-targets (instantaneous)",
+            "14:00:00",
+            "14:00:00",
+        ),
+        # This is an event at 14:05:00 with a duration of 15min.
+        # This constraint should span the intervals from 14:00 to 14:15 and from 14:15 to 14:30, but we are not reindexing properly.
+        pytest.param(
+            "soc-targets (15min lagged)",
+            "14:00:00",
+            "14:15:00",
+            marks=pytest.mark.xfail(
+                reason="we should re-index the series so that values of the original index that overlap are used."
+            ),
+        ),
+    ],
+)
+def test_add_storage_constraint_from_sensor(
+    add_battery_assets,
+    add_soc_targets,
+    sensor_name,
+    expected_start,
+    expected_end,
+    db,
+):
+    """
+    Test the handling of different values for the target SOC constraints as sensors in the StorageScheduler.
+    """
+    _, battery = get_sensors_from_db(db, add_battery_assets)
+    tz = pytz.timezone("Europe/Amsterdam")
+    start = tz.localize(datetime(2015, 1, 1))
+    end = tz.localize(datetime(2015, 1, 2))
+    resolution = timedelta(minutes=15)
+    soc_targets = add_soc_targets[sensor_name]
+
+    flex_model = {
+        "soc-max": 2,
+        "soc-min": 0,
+        "roundtrip-efficiency": 1,
+        "production-capacity": "0kW",
+        "soc-at-start": 0,
+    }
+
+    flex_model["soc-targets"] = {"sensor": soc_targets.id}
+
+    scheduler: Scheduler = StorageScheduler(
+        battery, start, end, resolution, flex_model=flex_model
+    )
+
+    scheduler_info = scheduler._prepare()
+    storage_constraints = scheduler_info[5][0]
+
+    expected_target_start = pd.Timedelta(expected_start) + start
+    expected_target_end = pd.Timedelta(expected_end) + start
+    expected_soc_target_value = 0.5 * timedelta(hours=1) / resolution
+
+    # convert dates from UTC to local time (Europe/Amsterdam)
+    equals = storage_constraints["equals"].tz_convert(tz)
+
+    # check that no value before expected_target_start is non-nan
+    assert all(equals[: expected_target_start - resolution].isna())
+
+    # check that no value after expected_target_end is non-nan
+    assert all(equals[expected_target_end + resolution :].isna())
+
+    # check that the values in the (expected_target_start, expected_target_end) are equal to the expected value
+    assert all(
+        equals[expected_target_start + resolution : expected_target_end]
+        == expected_soc_target_value
+    )
diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py
index 44fd8873..2d18e85e 100644
--- a/flexmeasures/data/schemas/tests/test_scheduling.py
+++ b/flexmeasures/data/schemas/tests/test_scheduling.py
@@ -11,9 +11,9 @@ from flexmeasures.data.schemas.scheduling.process import (
     ProcessType,
 )
 from flexmeasures.data.schemas.scheduling.storage import (
-    SOCValueSchema,
     StorageFlexModelSchema,
 )
+from flexmeasures.data.schemas.sensors import TimedEventSchema
 
 
 @pytest.mark.parametrize(
@@ -84,7 +84,7 @@ from flexmeasures.data.schemas.scheduling.storage import (
     ],
 )
 def test_soc_value_field(timing_input, expected_start, expected_end):
-    data = SOCValueSchema(timezone="Europe/Amsterdam").load(
+    data = TimedEventSchema(timezone="Europe/Amsterdam").load(
         {
             "value": 3,
             **timing_input,