diff --git a/documentation/api/introduction.rst b/documentation/api/introduction.rst index bac858783c..56d9219975 100644 --- a/documentation/api/introduction.rst +++ b/documentation/api/introduction.rst @@ -124,7 +124,8 @@ The response body will contain a JSON message with a ``status`` field set to ``" .. note:: FlexMeasures' built-in storage scheduler no longer computes a fallback schedule for infeasible problems. - Instead, ``soc-minima`` and ``soc-maxima`` are relaxed by default through ``"relax-soc-constraints": true``, while ``soc-min``, ``soc-max`` and ``soc-targets`` remain hard constraints. + Instead, dynamic ``soc-min`` and ``soc-max`` boundaries, including the legacy aliases ``soc-minima`` and ``soc-maxima``, are relaxed by default through ``"relax-soc-constraints": true``. + Fixed ``soc-min`` / ``soc-max`` values and exact ``soc-targets`` remain hard constraints. If hard constraints cannot be satisfied, the scheduling job fails and clients receive the failure reason when requesting the schedule. For custom schedulers that still define a fallback scheduler, server administrators can configure whether clients receive a 303 redirect (``FLEXMEASURES_FALLBACK_REDIRECT = True``) or whether FlexMeasures follows the fallback automatically and returns the fallback schedule directly (``FLEXMEASURES_FALLBACK_REDIRECT = False``, the default). diff --git a/documentation/changelog.rst b/documentation/changelog.rst index ea45585897..6d3c88ac89 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -18,6 +18,7 @@ New features * Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Relax storage SoC constraints by default and report infeasible storage schedules directly instead of saving fallback schedules [see `PR #2252 `_] +* Support defining dynamic storage ``soc-min`` and ``soc-max`` boundaries with sensor references or time series, including sensor ``default`` fallbacks; ``soc-minima`` and ``soc-maxima`` remain supported as legacy aliases [see `PR #2267 `_] Infrastructure / Support ---------------------- diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index 1debe7bb03..706527d937 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -264,13 +264,14 @@ If you model devices that *buffer* energy (e.g. thermal energy storage systems c However, here are some tips to model a buffer correctly: - Describe the thermal energy content in kWh or MWh. - - Set ``soc-minima`` to the accumulative usage forecast. + - Set dynamic ``soc-min`` values to the accumulative usage forecast. - Set ``charging-efficiency`` to the sensor describing the :abbr:`COP (coefficient of performance)` values. - Set ``storage-efficiency`` to a value below 100% to model (heat) loss. If the flex model describes an infeasible problem for the storage scheduler, the failure should remain visible. -By default, ``soc-minima`` and ``soc-maxima`` are relaxed into soft constraints, so the scheduler can still return a useful schedule when these boundaries cannot be fully met. -Exact ``soc-targets`` and physical ``soc-min`` / ``soc-max`` bounds remain hard constraints. +By default, dynamic ``soc-min`` and ``soc-max`` boundaries are relaxed into soft constraints, so the scheduler can still return a useful schedule when these boundaries cannot be fully met. +The legacy ``soc-minima`` and ``soc-maxima`` aliases follow the same behavior. +Exact ``soc-targets`` and fixed physical ``soc-min`` / ``soc-max`` bounds remain hard constraints. If those hard constraints make the problem infeasible, the scheduling job fails instead of producing a fallback schedule. It is important to take note of these failures. Often, misconfigured flex models are the reason. diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 4c66094d3e..b4e4d774e1 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -1433,7 +1433,7 @@ def trigger_schedule( power-capacity: 25 kW consumption-capacity: {sensor: 42} production-capacity: 30 kW - soc-minima: + soc-min: - {start: "2015-06-02T12:00:00+00:00", end: "2015-06-02T13:00:00+00:00", value: 10 kWh} - sensor: 932 consumption-capacity: 0 kW diff --git a/flexmeasures/api/v3_0/sensors.py b/flexmeasures/api/v3_0/sensors.py index 1c5e53d653..164dfde945 100644 --- a/flexmeasures/api/v3_0/sensors.py +++ b/flexmeasures/api/v3_0/sensors.py @@ -940,9 +940,9 @@ def trigger_schedule( soc-targets: - value: "25 kWh" datetime: "2015-06-02T16:00:00+00:00" - soc-minima: + soc-min: sensor: 300 - soc-min: "10 kWh" + default: "10 kWh" soc-max: "25 kWh" charging-efficiency: "120%" discharging-efficiency: diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 75705f06d6..57c32dae49 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1598,9 +1598,9 @@ def _build_soc_schedule( 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. - 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. + Note: dynamic soc-max values are routed to soc_maxima during deserialization, so + soc_max is still a fixed float after deserialization. The isinstance guard below + is therefore a defensive check for malformed data. """ soc_schedule = {} for d, flex_model_d in enumerate(flex_model): diff --git a/flexmeasures/data/models/planning/tests/test_solver.py b/flexmeasures/data/models/planning/tests/test_solver.py index 8c37ff05c2..43f5517494 100644 --- a/flexmeasures/data/models/planning/tests/test_solver.py +++ b/flexmeasures/data/models/planning/tests/test_solver.py @@ -2452,6 +2452,17 @@ def compute_schedule(flex_model): # because soc-maxima = soc-minima = soc-targets assert all(abs(soc[8:].values - expected_soc_schedule) < 1e-5) + # remove legacy soc-minima/soc-maxima and use dynamic canonical soc-min/soc-max + del flex_model["soc-minima"] + del flex_model["soc-maxima"] + flex_model["soc-min"] = {"sensor": soc_minima.id, "default": "0 MWh"} + flex_model["soc-max"] = {"sensor": soc_maxima.id, "default": "10 MWh"} + schedule = compute_schedule(flex_model) + + soc = check_constraints(power, schedule, soc_at_start) + + assert all(abs(soc[8:].values - expected_soc_schedule) < 1e-5) + @pytest.mark.parametrize("unit", [None, "MWh", "kWh"]) @pytest.mark.parametrize("soc_unit", ["kWh", "MWh"]) @@ -2537,7 +2548,7 @@ def test_battery_storage_different_units( @pytest.mark.parametrize( - "ts_field, ts_specs", + "ts_field, ts_specs, expected_charge, expected_discharge", [ # The battery only has time to charge up to 950 kWh halfway ( @@ -2549,6 +2560,8 @@ def test_battery_storage_different_units( "value": "850 kW", } ], + 0.85, + -0.85, ), # Same, but the event time is specified with a duration instead of an end time ( @@ -2560,6 +2573,8 @@ def test_battery_storage_different_units( "value": "850 kW", } ], + 0.85, + -0.85, ), # Can only charge up to 950 kWh halfway ( @@ -2570,6 +2585,20 @@ def test_battery_storage_different_units( "value": "950 kWh", } ], + 0.85, + -0.85, + ), + # Same dynamic maximum through the canonical soc-max field + ( + "soc-max", + [ + { + "datetime": "2015-01-02T16:00+01", + "value": "950 kWh", + } + ], + 0.85, + -0.85, ), # Must end up at a maximum of 200 kWh, for which it is cheapest to charge to 950 and then to discharge to 200 ( @@ -2581,6 +2610,47 @@ def test_battery_storage_different_units( "value": "200 kWh", } ], + 0.85, + -0.85, + ), + # Same dynamic maximum through the canonical soc-max field + ( + "soc-max", + [ + { + "start": "2015-01-02T16:45+01", + "duration": "PT15M", + "value": "200 kWh", + } + ], + 0.85, + -0.85, + ), + # Must end up at a minimum of 200 kWh, so it is cheapest to fill completely and then discharge to 200 + ( + "soc-minima", + [ + { + "start": "2015-01-02T16:45+01", + "duration": "PT15M", + "value": "200 kWh", + } + ], + 0.9, + -0.8, + ), + # Same dynamic minimum through the canonical soc-min field + ( + "soc-min", + [ + { + "start": "2015-01-02T16:45+01", + "duration": "PT15M", + "value": "200 kWh", + } + ], + 0.9, + -0.8, ), ], ) @@ -2589,6 +2659,8 @@ def test_battery_storage_with_time_series_in_flex_model( db, ts_field, ts_specs, + expected_charge, + expected_discharge, ): """ Test scheduling a 1 MWh battery for 2h with a low -> high price transition with @@ -2638,14 +2710,8 @@ def test_battery_storage_with_time_series_in_flex_model( soc_at_start = ur.Quantity(soc_at_start).to("MWh").magnitude check_constraints(battery, schedule, soc_at_start) - # charge 850 kWh in the cheap price period (100 kWh -> 950kWh) - assert schedule[:4].sum() * 0.25 == pytest.approx(0.85) - - # discharge fully or to what's needed in the expensive price period (950 kWh -> 100 or 200 kWh) - if ts_field == "soc-minima": - assert schedule[4:].sum() * 0.25 == pytest.approx(-0.75) - else: - assert schedule[4:].sum() * 0.25 == pytest.approx(-0.85) + assert schedule[:4].sum() * 0.25 == pytest.approx(expected_charge) + assert schedule[4:].sum() * 0.25 == pytest.approx(expected_discharge) def test_unavoidable_capacity_breach(): diff --git a/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py b/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py index da4a9cbc7b..7068a4763f 100644 --- a/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py +++ b/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py @@ -8,6 +8,7 @@ from flexmeasures.data.models.data_sources import DataSource from flexmeasures.data.schemas.sensors import SensorReference from flexmeasures.data.models.planning.utils import get_series_from_quantity_or_sensor +from flexmeasures.utils.unit_utils import ur def test_get_series_from_sensor_reference_source_filter_integration(fresh_db): @@ -86,6 +87,48 @@ def test_get_series_from_sensor_reference_source_filter_integration(fresh_db): assert result_forecaster.iloc[0] == pytest.approx(200.0) +def test_get_series_from_sensor_reference_default_fills_missing_values(fresh_db): + """A SensorReference default fills query slots with no matching sensor belief.""" + query_window = ( + pd.Timestamp("2025-06-01 08:00:00+02:00"), + pd.Timestamp("2025-06-01 08:30:00+02:00"), + ) + source = DataSource(name="test-default-source", type="scheduler") + fresh_db.session.add(source) + asset_type = GenericAssetType(name="test-asset-type-default") + fresh_db.session.add(asset_type) + asset = GenericAsset(name="test-asset-default", generic_asset_type=asset_type) + fresh_db.session.add(asset) + sensor = Sensor( + name="test-sensor-default", + generic_asset=asset, + event_resolution=timedelta(minutes=15), + unit="MW", + ) + fresh_db.session.add(sensor) + fresh_db.session.flush() + fresh_db.session.add( + TimedBelief( + event_start=query_window[0], + belief_horizon=timedelta(0), + event_value=0.1, + source=source, + sensor=sensor, + ) + ) + fresh_db.session.commit() + + result = get_series_from_quantity_or_sensor( + variable_quantity=SensorReference(sensor=sensor, default=ur.Quantity("1 MW")), + query_window=query_window, + resolution=sensor.event_resolution, + unit="kW", + as_instantaneous_events=False, + ) + + assert list(result) == pytest.approx([100.0, 1000.0]) + + def test_get_series_from_sensor_reference_sources_filter_integration(fresh_db): """A :class:`SensorReference` with ``sources`` returns only beliefs from the specified source. diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index 7cd79f7156..161d2d3810 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -299,6 +299,14 @@ def get_series_from_quantity_or_sensor( time_series = convert_units( time_series, variable_quantity.unit, unit, resolution ) + if variable_quantity.default is not None: + default_value = convert_units( + variable_quantity.default.magnitude, + str(variable_quantity.default.units), + unit, + resolution, + ) + time_series = time_series.fillna(default_value) elif isinstance(variable_quantity, Sensor): bdf: tb.BeliefsDataFrame = TimedBelief.search( variable_quantity, diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index ab24b7c621..e33d9781d5 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -611,7 +611,7 @@ def _to_currency_per_mwh(price_unit: str) -> str: "description": rst_to_openapi(metadata.SOC_MIN.description), "types": { "backend": "typeThree", - "ui": "One fixed value or a dynamic signal (via a sensor).", + "ui": "A fixed lower boundary or a dynamic lower boundary with an optional default fallback.", }, "example-units": EXAMPLE_UNIT_TYPES["energy"], }, @@ -620,7 +620,7 @@ def _to_currency_per_mwh(price_unit: str) -> str: "description": rst_to_openapi(metadata.SOC_MAX.description), "types": { "backend": "typeThree", - "ui": "One fixed value or a dynamic signal (via a sensor).", + "ui": "A fixed upper boundary or a dynamic upper boundary with an optional default fallback.", }, "example-units": EXAMPLE_UNIT_TYPES["energy"], }, @@ -629,7 +629,7 @@ def _to_currency_per_mwh(price_unit: str) -> str: "description": rst_to_openapi(metadata.SOC_MINIMA.description), "types": { "backend": "typeTwo", - "ui": "A sensor which records the state of charge.", + "ui": "Deprecated alias for dynamic soc-min values.", }, "example-units": EXAMPLE_UNIT_TYPES["energy"], }, @@ -638,7 +638,7 @@ def _to_currency_per_mwh(price_unit: str) -> str: "description": rst_to_openapi(metadata.SOC_MAXIMA.description), "types": { "backend": "typeTwo", - "ui": "A sensor which records the state of charge.", + "ui": "Deprecated alias for dynamic soc-max values.", }, "example-units": EXAMPLE_UNIT_TYPES["energy"], }, diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index bfa2db6be7..f934b015a4 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -105,20 +105,20 @@ def to_dict(self): example="260 EUR/MW", ) SOC_MINIMA_BREACH_PRICE = MetaData( - description="""This **penalty value** is used to discourage the violation of ``soc-minima`` constraints in the flex-model, which the scheduler will attempt to minimize. + description="""This **penalty value** is used to discourage the violation of dynamic lower SoC boundary constraints in the flex-model, which the scheduler will attempt to minimize. It must use the same currency as the other price settings and cannot be negative. While it's an internal nudge to steer the scheduler—and doesn't represent a real-life cost—it should still be chosen in proportion to the actual energy prices at your site. If it's too high, it will overly dominate other constraints; if it's too low, it will have no effect. -Without this value, the soc-minima become hard constraints, which means that any infeasible state-of-charge minima would prevent a complete schedule from being computed. [#penalty_field]_ [#breach_field]_ +Without this value, dynamic ``soc-min`` boundaries and legacy ``soc-minima`` boundaries become hard constraints, which means that any infeasible state-of-charge minima would prevent a complete schedule from being computed. [#penalty_field]_ [#breach_field]_ """, example="120 EUR/kWh", ) SOC_MAXIMA_BREACH_PRICE = MetaData( - description="""This **penalty value** is used to discourage the violation of ``soc-maxima`` constraints in the flex-model, which the scheduler will attempt to minimize. + description="""This **penalty value** is used to discourage the violation of dynamic upper SoC boundary constraints in the flex-model, which the scheduler will attempt to minimize. It must use the same currency as the other price settings and cannot be negative. While it's an **internal nudge** to steer the scheduler—and doesn't represent a real-life cost—it should still be chosen in proportion to the actual energy prices at your site. If it's too high, it will overly dominate other constraints; if it's too low, it will have no effect. -Without this value, the soc-maxima become hard constraints, which means that any infeasible state-of-charge maxima would prevent a complete schedule from being computed. [#penalty_field]_ [#breach_field]_ +Without this value, dynamic ``soc-max`` boundaries and legacy ``soc-maxima`` boundaries become hard constraints, which means that any infeasible state-of-charge maxima would prevent a complete schedule from being computed. [#penalty_field]_ [#breach_field]_ """, example="120 EUR/kWh", ) @@ -230,24 +230,29 @@ def to_dict(self): example="kWh", ) SOC_MIN = MetaData( - description="""A constant and non-negotiable lower boundary for all SoC values in the schedule. + description="""Lower boundary for all SoC values in the schedule. If omitted, no lower boundary is applied. -If used, this is regarded as an unsurpassable physical limitation. -To set softer boundaries, use the ``soc-minima`` flex-model field instead together with the ``soc-minima-breach-price`` field in the flex-context. [#quantity_field]_ +When passed as a fixed quantity, this is regarded as an unsurpassable physical limitation. +When passed as a sensor reference or time series, it defines dynamic lower boundaries. Dynamic boundaries are soft constraints by default, because ``relax-soc-constraints`` defaults to ``True`` and supplies a default ``soc-minima-breach-price``. +Sensor references may include a ``default`` fallback quantity for missing sensor values, for example ``{"sensor": 50, "default": "0 kWh"}``. +Set ``relax-soc-constraints`` to ``False`` to keep dynamic lower boundaries as hard constraints unless ``soc-minima-breach-price`` is supplied explicitly. [#maximum_overlap]_ [#projecting_scheduling_constraints]_ """, - example="2.5 kWh", + example={"sensor": 50, "default": "0 kWh"}, ) SOC_MAX = MetaData( - description="""A constant and non-negotiable upper boundary for all values in the schedule (for storage devices, this defaults to max soc-target, if that is provided). + description="""Upper boundary for all SoC values in the schedule (for storage devices, this defaults to max soc-target, if that is provided). If omitted, no upper boundary is applied. -If used, this is regarded as an unsurpassable physical limitation. -To set softer boundaries, use the ``soc-maxima`` flex-model field instead together with the ``soc-maxima-breach-price`` field in the flex-context. [#quantity_field]_ +When passed as a fixed quantity, this is regarded as an unsurpassable physical limitation. +When passed as a sensor reference or time series, it defines dynamic upper boundaries. Dynamic boundaries are soft constraints by default, because ``relax-soc-constraints`` defaults to ``True`` and supplies a default ``soc-maxima-breach-price``. +Sensor references may include a ``default`` fallback quantity for missing sensor values, for example ``{"sensor": 51, "default": "100 kWh"}``. +Set ``relax-soc-constraints`` to ``False`` to keep dynamic upper boundaries as hard constraints unless ``soc-maxima-breach-price`` is supplied explicitly. [#minimum_overlap]_ [#projecting_scheduling_constraints]_ """, - example="7 kWh", + example={"sensor": 51, "default": "100 kWh"}, ) SOC_MINIMA = MetaData( - description="""Set points that form lower boundaries, e.g. to target a full car battery in the morning. -The ``soc-minima`` are soft constraints in the optimization problem by default, because ``relax-soc-constraints`` defaults to ``True`` and supplies a default ``soc-minima-breach-price``. + description="""[Deprecated field] Use dynamic ``soc-min`` values instead. +Set points that form lower boundaries, e.g. to target a full car battery in the morning. +The ``soc-minima`` legacy alias is soft in the optimization problem by default, because ``relax-soc-constraints`` defaults to ``True`` and supplies a default ``soc-minima-breach-price``. Set ``relax-soc-constraints`` to ``False`` to keep them as hard constraints unless ``soc-minima-breach-price`` is supplied explicitly [#maximum_overlap]_. Both single points in time and ranges are possible, see example.""", example=[ @@ -260,8 +265,9 @@ def to_dict(self): ], ) SOC_MAXIMA = MetaData( - description="""Set points that form upper boundaries at certain times, e.g. to target an empty heat buffer before a maintenance window. -The ``soc-maxima`` are soft constraints in the optimization problem by default, because ``relax-soc-constraints`` defaults to ``True`` and supplies a default ``soc-maxima-breach-price``. + description="""[Deprecated field] Use dynamic ``soc-max`` values instead. +Set points that form upper boundaries at certain times, e.g. to target an empty heat buffer before a maintenance window. +The ``soc-maxima`` legacy alias is soft in the optimization problem by default, because ``relax-soc-constraints`` defaults to ``True`` and supplies a default ``soc-maxima-breach-price``. Set ``relax-soc-constraints`` to ``False`` to keep them as hard constraints unless ``soc-maxima-breach-price`` is supplied explicitly. [#minimum_overlap]_""", example=[ { diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 8453c2149d..1f82aae89c 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -113,19 +113,20 @@ class StorageFlexModelSchema(Schema): metadata=metadata.SOC_AT_START.to_dict(), ) - soc_min = QuantityField( - validate=validate.Range(min=ur.Quantity("0 MWh")), + soc_min = VariableQuantityField( to_unit="MWh", default_src_unit="dimensionless", # placeholder, overridden in __init__ - return_magnitude=False, + timezone="placeholder", data_key="soc-min", + value_validator=validate.Range(min=0), metadata=metadata.SOC_MIN.to_dict(), ) - soc_max = QuantityField( + soc_max = VariableQuantityField( to_unit="MWh", default_src_unit="dimensionless", # placeholder, overridden in __init__ - return_magnitude=False, + timezone="placeholder", data_key="soc-max", + value_validator=validate.Range(min=0), metadata=metadata.SOC_MAX.to_dict(), ) @@ -167,7 +168,8 @@ class StorageFlexModelSchema(Schema): default_src_unit="dimensionless", # placeholder, overridden in __init__ timezone="placeholder", data_key="soc-maxima", - metadata=metadata.SOC_MAXIMA.to_dict(), + value_validator=validate.Range(min=0), + metadata={**metadata.SOC_MAXIMA.to_dict(), "deprecated": True}, ) soc_minima = VariableQuantityField( @@ -176,7 +178,7 @@ class StorageFlexModelSchema(Schema): timezone="placeholder", data_key="soc-minima", value_validator=validate.Range(min=0), - metadata=metadata.SOC_MINIMA.to_dict(), + metadata={**metadata.SOC_MINIMA.to_dict(), "deprecated": True}, ) soc_targets = VariableQuantityField( @@ -276,35 +278,14 @@ def __init__( else: default_soc_unit = "MWh" - self.soc_maxima = VariableQuantityField( - to_unit="MWh", - default_src_unit=default_soc_unit, - timezone=self.timezone, - event_resolution=self.flooring_resolution, - data_key="soc-maxima", - ) - - self.soc_minima = VariableQuantityField( - to_unit="MWh", - default_src_unit=default_soc_unit, - timezone=self.timezone, - event_resolution=self.flooring_resolution, - data_key="soc-minima", - value_validator=validate.Range(min=0), - ) - self.soc_targets = VariableQuantityField( - to_unit="MWh", - default_src_unit=default_soc_unit, - timezone=self.timezone, - event_resolution=self.flooring_resolution, - data_key="soc-targets", - ) - super().__init__(*args, **kwargs) if default_soc_unit is not None: for field in self.fields.keys(): if field.startswith("soc_"): setattr(self.fields[field], "default_src_unit", default_soc_unit) + for field in ("soc_min", "soc_max", "soc_minima", "soc_maxima", "soc_targets"): + setattr(self.fields[field], "timezone", self.timezone) + setattr(self.fields[field], "event_resolution", self.flooring_resolution) @validates_schema def check_whether_targets_exceed_max_planning_horizon(self, data: dict, **kwargs): @@ -420,11 +401,28 @@ def post_load_sequence(self, data: dict, **kwargs) -> dict: if data.get("soc_at_start") is not None: data["soc_at_start"] = (data["soc_at_start"] / ur.Quantity("MWh")).magnitude + dynamic_types = (Sensor, SensorReference, list) + if isinstance(data.get("soc_min"), dynamic_types): + if data.get("soc_minima") is not None: + raise ValidationError( + "Fields `soc-min` and `soc-minima` are mutually exclusive.", + field_name="soc-min", + ) + data["soc_minima"] = data.pop("soc_min") + + if isinstance(data.get("soc_max"), dynamic_types): + if data.get("soc_maxima") is not None: + raise ValidationError( + "Fields `soc-max` and `soc-maxima` are mutually exclusive.", + field_name="soc-max", + ) + data["soc_maxima"] = data.pop("soc_max") + # Convert soc_min to dimensionless - if data.get("soc_min") is not None: + if isinstance(data.get("soc_min"), ur.Quantity): data["soc_min"] = (data["soc_min"] / ur.Quantity("MWh")).magnitude # Convert soc_max to dimensionless - if data.get("soc_max") is not None: + if isinstance(data.get("soc_max"), ur.Quantity): data["soc_max"] = (data["soc_max"] / ur.Quantity("MWh")).magnitude return data @@ -459,6 +457,7 @@ class DBStorageFlexModelSchema(Schema): data_key="soc-minima", required=False, value_validator=validate.Range(min=0), + metadata={"deprecated": True}, ) soc_maxima = VariableQuantityField( @@ -466,6 +465,7 @@ class DBStorageFlexModelSchema(Schema): data_key="soc-maxima", required=False, value_validator=validate.Range(min=0), + metadata={"deprecated": True}, ) soc_targets = VariableQuantityField( diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py index fc510adf05..10d20a6fcb 100644 --- a/flexmeasures/data/schemas/sensors.py +++ b/flexmeasures/data/schemas/sensors.py @@ -24,6 +24,7 @@ ) import marshmallow.validate as validate from pandas.api.types import is_numeric_dtype +from pint.errors import PintError import timely_beliefs as tb from werkzeug.datastructures import FileStorage from marshmallow.validate import Validator @@ -360,6 +361,11 @@ def _serialize(self, value: Sensor, attr, obj, **kwargs) -> int: class VariableQuantityField(MarshmallowClickMixin, fields.Field): + _UNSUPPORTED_VALUE_TYPE_MESSAGE = ( + "Unsupported value type. `{value_type}` was provided but only dict, list, " + "str, pint Quantity, tuple, and numeric values with a default source unit are supported." + ) + def __init__( self, to_unit, @@ -429,20 +435,48 @@ def __init__( @with_appcontext_if_needed() def _deserialize( - self, value: dict[str, int] | list[dict] | str, attr, data, **kwargs - ) -> Sensor | list[dict] | ur.Quantity: + self, + value: ( + dict[str, Any] + | list[dict] + | str + | ur.Quantity + | tuple[Any, ...] + | numbers.Real + ), + attr, + data, + **kwargs, + ) -> Sensor | SensorReference | list[dict] | ur.Quantity: if isinstance(value, dict): - return self._deserialize_dict(value) + return self._deserialize_dict(value, attr, data, **kwargs) elif isinstance(value, list): return self._deserialize_list(value) elif isinstance(value, str): return self._deserialize_str(value) + elif isinstance(value, ur.Quantity): + return value.to(self.to_unit) + elif isinstance(value, tuple): + try: + return ur.Quantity.from_tuple(value).to(self.to_unit) + except (PintError, TypeError, ValueError, AttributeError, IndexError): + if ( + len(value) == 1 + and isinstance(value[0], numbers.Real) + and self.default_src_unit is not None + ): + return self._deserialize_numeric(value[0], attr, data, **kwargs) + if len(value) == 2: + return self._deserialize_str(f"{value[0]} {value[1]}") + raise FMValidationError( + self._UNSUPPORTED_VALUE_TYPE_MESSAGE.format(value_type=type(value)) + ) elif isinstance(value, numbers.Real) and self.default_src_unit is not None: return self._deserialize_numeric(value, attr, data, **kwargs) else: raise FMValidationError( - f"Unsupported value type. `{type(value)}` was provided but only dict, list and str are supported." + self._UNSUPPORTED_VALUE_TYPE_MESSAGE.format(value_type=type(value)) ) _SOURCE_FILTER_KEYS = frozenset( @@ -501,13 +535,15 @@ def _deserialize_source_filters(self, value: dict[str, Any]) -> tuple[ return source_types, exclude_source_types, sources, source_account - def _deserialize_dict(self, value: dict[str, Any]) -> Sensor | SensorReference: + def _deserialize_dict( + self, value: dict[str, Any], attr, data, **kwargs + ) -> Sensor | SensorReference: """Deserialize a sensor reference to a Sensor or SensorReference. - Returns a plain :class:`Sensor` when no source filter keys are present - (backward compatible), and a :class:`SensorReference` when any of - ``source-types``, ``exclude-source-types``, ``sources``, or - ``source-account`` are provided. + Returns a plain :class:`Sensor` when no source filter or default keys are + present (backward compatible), and a :class:`SensorReference` when any of + ``source-types``, ``exclude-source-types``, ``sources``, ``source-account`` + or ``default`` are provided. """ if "sensor" not in value: raise FMValidationError("Dictionary provided but `sensor` key not found.") @@ -527,8 +563,12 @@ def _deserialize_dict(self, value: dict[str, Any]) -> Sensor | SensorReference: unit=self.to_unit if not self.to_unit.startswith("/") else None ).deserialize(value["sensor"], None, None) - # If source filter keys are present, return a SensorReference instead of a plain Sensor. - if self._SOURCE_FILTER_KEYS.isdisjoint(value.keys()): + default = None + if "default" in value and value["default"] is not None: + default = self._deserialize_default(value["default"], attr, data, **kwargs) + + # If no source filter or default keys are present, keep returning a plain Sensor. + if self._SOURCE_FILTER_KEYS.isdisjoint(value.keys()) and default is None: return sensor # backward compat: no filters → plain Sensor source_types, exclude_source_types, sources, source_account = ( @@ -540,8 +580,23 @@ def _deserialize_dict(self, value: dict[str, Any]) -> Sensor | SensorReference: exclude_source_types=exclude_source_types, sources=sources, source_account=source_account, + default=default, ) + def _deserialize_default(self, value, attr, data, **kwargs) -> ur.Quantity: + """Deserialize a sensor reference fallback value.""" + if isinstance(value, str): + default = self._deserialize_str(value) + elif isinstance(value, numbers.Real) and self.default_src_unit is not None: + default = self._deserialize_numeric(value, attr, data, **kwargs) + else: + raise FMValidationError( + "Sensor reference `default` must be a quantity string or a numeric value with a known default source unit." + ) + if self.value_validator is not None: + self.value_validator(default) + return default + def _deserialize_list(self, value: list[dict]) -> list[dict]: """Deserialize a time series to a list of timed events.""" if self.return_magnitude is True: @@ -593,6 +648,8 @@ def _serialize( sensor_reference["source-account"] = [ account.id for account in value.source_account ] + if value.default is not None: + sensor_reference["default"] = str(value.default.to(self.to_unit)) return sensor_reference elif isinstance(value, Sensor): return dict(sensor=value.id) @@ -948,13 +1005,13 @@ class QuantitySchema(Schema): @dataclass class SensorReference: - """A sensor reference that wraps a Sensor with optional source filters for belief queries. + """A sensor reference that wraps a Sensor with optional query settings. Exposes the same ``unit``, ``id``, and ``event_resolution`` properties as a plain :class:`~flexmeasures.data.models.time_series.Sensor`, so code that reads those - properties works without modification. The source filters are passed through to - :meth:`TimedBelief.search ` - in :func:`~flexmeasures.data.models.planning.utils.get_series_from_quantity_or_sensor`. + properties works without modification. The source filters and optional default + value are passed through to + :func:`~flexmeasures.data.models.planning.utils.get_series_from_quantity_or_sensor`. """ sensor: Sensor @@ -962,6 +1019,7 @@ class SensorReference: exclude_source_types: list[str] | None = field(default=None) sources: list[DataSource] | None = field(default=None) source_account: list[Account] | None = field(default=None) + default: ur.Quantity | None = field(default=None) @property def unit(self) -> str: @@ -980,7 +1038,7 @@ def event_resolution(self) -> timedelta: class SensorReferenceSchema(Schema): - """Sensor reference with optional source filters.""" + """Sensor reference with optional source filters and fallback value.""" class Meta: description = "Sensor reference from which to look up a variable quantity." @@ -1022,6 +1080,14 @@ class Meta: description="Only use beliefs from data sources linked to these account IDs.", ), ) + default = fields.String( + load_default=None, + allow_none=True, + metadata=dict( + description="Fallback quantity to use when the referenced sensor has missing values.", + example="0 kWh", + ), + ) class TimeSeriesSchema(Schema): @@ -1031,9 +1097,21 @@ class TimeSeriesSchema(Schema): fields.Dict, required=True, metadata=dict( - description="Time series specification containing a list of segments that together describe a variable quantity.", + description=( + "Time series specification containing a list of segments that together " + "describe a variable quantity. Each segment may specify either " + "`datetime`, `start` and `end`, `start` and `duration`, or `end` and " + "`duration`." + ), example=[ - {"value": "23 kW", "start": "2025-11-20T15:15+01", "duration": "PT1H"} + {"value": "23 kW", "datetime": "2025-11-20T15:15+01"}, + { + "value": "24 kW", + "start": "2025-11-20T16:00+01", + "end": "2025-11-20T17:00+01", + }, + {"value": "25 kW", "start": "2025-11-20T17:00+01", "duration": "PT1H"}, + {"value": "26 kW", "end": "2025-11-20T19:00+01", "duration": "PT1H"}, ], ), ) diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index 4522d97484..a182142589 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -15,7 +15,12 @@ DBStorageFlexModelSchema, ) from flexmeasures.data.models.time_series import Sensor -from flexmeasures.data.schemas.sensors import TimedEventSchema, VariableQuantityField +from flexmeasures.data.schemas.sensors import ( + SensorReference, + TimedEventSchema, + VariableQuantityField, +) +from flexmeasures.utils.unit_utils import ur @pytest.mark.parametrize( @@ -619,7 +624,7 @@ def check_schema_loads_data(schema, data, fails): ( {"site-power-capacity": 100}, { - "site-power-capacity": f"Unsupported value type. `{type(100)}` was provided but only dict, list and str are supported." + "site-power-capacity": f"Unsupported value type. `{type(100)}` was provided but only dict, list, str, pint Quantity, tuple, and numeric values with a default source unit are supported." }, ), ( @@ -878,6 +883,38 @@ def test_storage_flex_model_schema_rejects_filtered_production( assert "cannot use source filters" in str(exc_info.value) +def test_soc_min_sensor_reference_with_default_loads_as_dynamic_minimum( + setup_dummy_sensors, +): + energy_sensor, _, _, _ = setup_dummy_sensors + schema = StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None) + + loaded_flex_model = schema.load( + {"soc-min": {"sensor": energy_sensor.id, "default": "0 kWh"}} + ) + + assert "soc_min" not in loaded_flex_model + assert isinstance(loaded_flex_model["soc_minima"], SensorReference) + assert loaded_flex_model["soc_minima"].sensor == energy_sensor + assert loaded_flex_model["soc_minima"].default == ur.Quantity("0 MWh") + + +def test_soc_max_sensor_reference_with_default_loads_as_dynamic_maximum( + setup_dummy_sensors, +): + energy_sensor, _, _, _ = setup_dummy_sensors + schema = StorageFlexModelSchema(start=datetime(2026, 6, 1), sensor=None) + + loaded_flex_model = schema.load( + {"soc-max": {"sensor": energy_sensor.id, "default": "1000 kWh"}} + ) + + assert "soc_max" not in loaded_flex_model + assert isinstance(loaded_flex_model["soc_maxima"], SensorReference) + assert loaded_flex_model["soc_maxima"].sensor == energy_sensor + assert loaded_flex_model["soc_maxima"].default == ur.Quantity("1 MWh") + + @pytest.mark.parametrize( ["flex_model", "fails"], [ @@ -889,6 +926,103 @@ def test_storage_flex_model_schema_rejects_filtered_production( {"soc-min": "3500 kWh"}, False, ), + ( + {"soc-max": "3500 kWh"}, + False, + ), + ( + {"soc-max": (1, "MWh")}, + False, + ), + ( + {"soc-max": (1,)}, + [ + False, + { + "soc-max": "Unsupported value type. `` was provided but only dict, list, str, pint Quantity, tuple, and numeric values with a default source unit are supported." + }, + ], + ), + ( + {"soc-max": ur.Quantity("1 MWh")}, + False, + ), + ( + {"soc-max": ur.Quantity("1 MWh").to_tuple()}, + False, + ), + ( + {"soc-min": {"sensor": "energy-sensor", "default": "0 kWh"}}, + False, + ), + ( + {"soc-max": {"sensor": "energy-sensor", "default": "1 MWh"}}, + False, + ), + ( + {"soc-min": {"sensor": "price-sensor", "default": "0 kWh"}}, + {"soc-min": "Cannot convert EUR/MWh to MWh"}, + ), + ( + {"soc-max": {"sensor": "price-sensor", "default": "1 MWh"}}, + {"soc-max": "Cannot convert EUR/MWh to MWh"}, + ), + ( + { + "soc-min": [ + { + "datetime": "2026-06-01T12:00:00+00:00", + "value": "1 MWh", + } + ] + }, + [ + False, + { + "soc-min": "A time series specification (listing segments) is not supported when storing flex-model fields." + }, + ], + ), + ( + { + "soc-max": [ + { + "datetime": "2026-06-01T12:00:00+00:00", + "value": "2 MWh", + } + ] + }, + [ + False, + { + "soc-max": "A time series specification (listing segments) is not supported when storing flex-model fields." + }, + ], + ), + ( + { + "soc-min": {"sensor": "energy-sensor", "default": "0 kWh"}, + "soc-minima": {"sensor": "energy-sensor"}, + }, + [ + { + "soc-min": "Fields `soc-min` and `soc-minima` are mutually exclusive." + }, + False, + ], + ), + ( + { + "soc-max": {"sensor": "energy-sensor", "default": "1 MWh"}, + "soc-maxima": {"sensor": "energy-sensor"}, + }, + [ + { + "soc-max": "Fields `soc-max` and `soc-maxima` are mutually exclusive." + }, + False, + ], + ), ( {"soc-minima": {"sensor": "energy-sensor"}}, False, diff --git a/flexmeasures/data/schemas/tests/test_sensor.py b/flexmeasures/data/schemas/tests/test_sensor.py index ec574b0bcf..7f3ab49d01 100644 --- a/flexmeasures/data/schemas/tests/test_sensor.py +++ b/flexmeasures/data/schemas/tests/test_sensor.py @@ -5,6 +5,7 @@ from flexmeasures.data.schemas.sensors import ( QuantityOrSensor, SensorReference, + SensorReferenceSchema, VariableQuantityField, floor_bdf_event_starts, ) @@ -210,6 +211,27 @@ def test_sensor_reference_backward_compatible(setup_dummy_sensors): assert result.id == sensor1.id +def test_sensor_reference_with_default(setup_dummy_sensors): + """``{"sensor": , "default": ...}`` deserializes to a SensorReference.""" + sensor1, _, _, _ = setup_dummy_sensors + field = VariableQuantityField(to_unit="MWh", return_magnitude=False) + + result = field.deserialize({"sensor": sensor1.id, "default": "500 kWh"}) + + assert isinstance(result, SensorReference) + assert result.sensor == sensor1 + assert result.default == ur.Quantity("0.5 MWh") + + +def test_sensor_reference_schema_accepts_null_default(setup_dummy_sensors): + sensor1, _, _, _ = setup_dummy_sensors + + result = SensorReferenceSchema().load({"sensor": sensor1.id, "default": None}) + + assert result["sensor"] == sensor1 + assert result["default"] is None + + def test_sensor_reference_with_source_types(setup_dummy_sensors): """``{"sensor": , "source-types": [...]}`` deserializes to a :class:`SensorReference`. @@ -332,6 +354,18 @@ def test_sensor_reference_serialization_preserves_source_filters( } +def test_sensor_reference_serialization_preserves_default(setup_dummy_sensors): + sensor1, _, _, _ = setup_dummy_sensors + field = VariableQuantityField(to_unit="MWh", return_magnitude=False) + source_reference = field.deserialize({"sensor": sensor1.id, "default": "500 kWh"}) + + assert isinstance(source_reference, SensorReference) + assert serialize_variable_quantity(source_reference) == { + "sensor": sensor1.id, + "default": "0.5 MWh", + } + + def test_sensor_reference_filters_are_kept_per_reference( setup_dummy_sensors, setup_sources, db ): diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6109a45f4d..b36d73c184 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1456,10 +1456,10 @@ "datetime": "2015-06-02T16:00:00+00:00" } ], - "soc-minima": { - "sensor": 300 + "soc-min": { + "sensor": 300, + "default": "10 kWh" }, - "soc-min": "10 kWh", "soc-max": "25 kWh", "charging-efficiency": "120%", "discharging-efficiency": { @@ -3905,7 +3905,7 @@ "sensor": 42 }, "production-capacity": "30 kW", - "soc-minima": [ + "soc-min": [ { "start": "2015-06-02T12:00:00+00:00", "end": "2015-06-02T13:00:00+00:00", @@ -4509,6 +4509,15 @@ "items": { "type": "integer" } + }, + "default": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Fallback quantity to use when the referenced sensor has missing values.", + "example": "0 kWh" } }, "required": [ @@ -4518,11 +4527,25 @@ }, "TimeSeries": { "type": "array", - "description": "Time series specification containing a list of segments that together describe a variable quantity.", + "description": "Time series specification containing a list of segments that together describe a variable quantity. Each segment may specify either `datetime`, `start` and `end`, `start` and `duration`, or `end` and `duration`.", "example": [ { "value": "23 kW", - "start": "2025-11-20T15:15+01", + "datetime": "2025-11-20T15:15+01" + }, + { + "value": "24 kW", + "start": "2025-11-20T16:00+01", + "end": "2025-11-20T17:00+01" + }, + { + "value": "25 kW", + "start": "2025-11-20T17:00+01", + "duration": "PT1H" + }, + { + "value": "26 kW", + "end": "2025-11-20T19:00+01", "duration": "PT1H" } ], @@ -4581,12 +4604,12 @@ "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "soc-minima-breach-price": { - "description": "This penalty value is used to discourage the violation of soc-minima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-minima become hard constraints, which means that any infeasible state-of-charge minima would prevent a complete schedule from being computed.\n", + "description": "This penalty value is used to discourage the violation of dynamic lower SoC boundary constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, dynamic soc-min boundaries and legacy soc-minima boundaries become hard constraints, which means that any infeasible state-of-charge minima would prevent a complete schedule from being computed.\n", "example": "120 EUR/kWh", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "soc-maxima-breach-price": { - "description": "This penalty value is used to discourage the violation of soc-maxima constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, the soc-maxima become hard constraints, which means that any infeasible state-of-charge maxima would prevent a complete schedule from being computed.\n", + "description": "This penalty value is used to discourage the violation of dynamic upper SoC boundary constraints in the flex-model, which the scheduler will attempt to minimize.\nIt must use the same currency as the other price settings and cannot be negative.\nWhile it's an internal nudge to steer the scheduler\u2014and doesn't represent a real-life cost\u2014it should still be chosen in proportion to the actual energy prices at your site.\nIf it's too high, it will overly dominate other constraints; if it's too low, it will have no effect.\nWithout this value, dynamic soc-max boundaries and legacy soc-maxima boundaries become hard constraints, which means that any infeasible state-of-charge maxima would prevent a complete schedule from being computed.\n", "example": "120 EUR/kWh", "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, @@ -6099,15 +6122,20 @@ "example": "3.1 kWh" }, "soc-min": { - "type": "string", - "x-minimum": "0 MWh", - "description": "A constant and non-negotiable lower boundary for all SoC values in the schedule.\nIf omitted, no lower boundary is applied.\nIf used, this is regarded as an unsurpassable physical limitation.\nTo set softer boundaries, use the soc-minima flex-model field instead together with the soc-minima-breach-price field in the flex-context.\n", - "example": "2.5 kWh" + "description": "Lower boundary for all SoC values in the schedule.\nIf omitted, no lower boundary is applied.\nWhen passed as a fixed quantity, this is regarded as an unsurpassable physical limitation.\nWhen passed as a sensor reference or time series, it defines dynamic lower boundaries. Dynamic boundaries are soft constraints by default, because relax-soc-constraints defaults to True and supplies a default soc-minima-breach-price.\nSensor references may include a default fallback quantity for missing sensor values, for example {\"sensor\": 50, \"default\": \"0 kWh\"}.\nSet relax-soc-constraints to False to keep dynamic lower boundaries as hard constraints unless soc-minima-breach-price is supplied explicitly.\n", + "example": { + "sensor": 50, + "default": "0 kWh" + }, + "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "soc-max": { - "type": "string", - "description": "A constant and non-negotiable upper boundary for all values in the schedule (for storage devices, this defaults to max soc-target, if that is provided).\nIf omitted, no upper boundary is applied.\nIf used, this is regarded as an unsurpassable physical limitation.\nTo set softer boundaries, use the soc-maxima flex-model field instead together with the soc-maxima-breach-price field in the flex-context.\n", - "example": "7 kWh" + "description": "Upper boundary for all SoC values in the schedule (for storage devices, this defaults to max soc-target, if that is provided).\nIf omitted, no upper boundary is applied.\nWhen passed as a fixed quantity, this is regarded as an unsurpassable physical limitation.\nWhen passed as a sensor reference or time series, it defines dynamic upper boundaries. Dynamic boundaries are soft constraints by default, because relax-soc-constraints defaults to True and supplies a default soc-maxima-breach-price.\nSensor references may include a default fallback quantity for missing sensor values, for example {\"sensor\": 51, \"default\": \"100 kWh\"}.\nSet relax-soc-constraints to False to keep dynamic upper boundaries as hard constraints unless soc-maxima-breach-price is supplied explicitly.\n", + "example": { + "sensor": 51, + "default": "100 kWh" + }, + "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "power-capacity": { "description": "Symmetric device-level power constraint. How much power can be applied to this asset in either direction.\nIf omitted, the scheduler infers this limit from the greatest of consumption-capacity and production-capacity when either is configured, before falling back to site-power-capacity.\nWhen exactly one of consumption-capacity or production-capacity is configured to non-zero capacity, the missing opposite capacity defaults to zero.", @@ -6139,7 +6167,7 @@ "example": true }, "soc-maxima": { - "description": "Set points that form upper boundaries at certain times, e.g. to target an empty heat buffer before a maintenance window.\nThe soc-maxima are soft constraints in the optimization problem by default, because relax-soc-constraints defaults to True and supplies a default soc-maxima-breach-price.\nSet relax-soc-constraints to False to keep them as hard constraints unless soc-maxima-breach-price is supplied explicitly.", + "description": "[Deprecated field] Use dynamic soc-max values instead.\nSet points that form upper boundaries at certain times, e.g. to target an empty heat buffer before a maintenance window.\nThe soc-maxima legacy alias is soft in the optimization problem by default, because relax-soc-constraints defaults to True and supplies a default soc-maxima-breach-price.\nSet relax-soc-constraints to False to keep them as hard constraints unless soc-maxima-breach-price is supplied explicitly.", "example": [ { "value": "51 kWh", @@ -6147,10 +6175,11 @@ "end": "2024-02-05T13:30:00+01:00" } ], + "deprecated": true, "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "soc-minima": { - "description": "Set points that form lower boundaries, e.g. to target a full car battery in the morning.\nThe soc-minima are soft constraints in the optimization problem by default, because relax-soc-constraints defaults to True and supplies a default soc-minima-breach-price.\nSet relax-soc-constraints to False to keep them as hard constraints unless soc-minima-breach-price is supplied explicitly.\nBoth single points in time and ranges are possible, see example.", + "description": "[Deprecated field] Use dynamic soc-min values instead.\nSet points that form lower boundaries, e.g. to target a full car battery in the morning.\nThe soc-minima legacy alias is soft in the optimization problem by default, because relax-soc-constraints defaults to True and supplies a default soc-minima-breach-price.\nSet relax-soc-constraints to False to keep them as hard constraints unless soc-minima-breach-price is supplied explicitly.\nBoth single points in time and ranges are possible, see example.", "example": [ { "datetime": "2024-02-05T08:00:00+01:00", @@ -6162,6 +6191,7 @@ "end": "2024-02-05T13:30:00+01:00" } ], + "deprecated": true, "$ref": "#/components/schemas/VariableQuantityOpenAPI" }, "soc-targets": {