123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- from __future__ import annotations
- from datetime import datetime, timedelta
- from flask import current_app
- from marshmallow import (
- Schema,
- post_load,
- validate,
- validates_schema,
- fields,
- validates,
- )
- from marshmallow.validate import OneOf, ValidationError
- from flexmeasures.data.models.time_series import Sensor
- from flexmeasures.data.schemas.units import QuantityField
- from flexmeasures.data.schemas.sensors import VariableQuantityField
- from flexmeasures.utils.unit_utils import ur
- class EfficiencyField(QuantityField):
- """Field that deserializes to a Quantity with % units. Must be greater than 0% and less than or equal to 100%.
- Examples:
- >>> ef = EfficiencyField()
- >>> ef.deserialize(0.9)
- <Quantity(90.0, 'percent')>
- >>> ef.deserialize("90%")
- <Quantity(90, 'percent')>
- >>> ef.deserialize("0%")
- Traceback (most recent call last):
- ...
- marshmallow.exceptions.ValidationError: ['Must be greater than 0 and less than or equal to 1.']
- """
- def __init__(self, *args, **kwargs):
- super().__init__(
- "%",
- validate=validate.Range(
- min=0, max=1, min_inclusive=False, max_inclusive=True
- ),
- *args,
- **kwargs,
- )
- class StorageFlexModelSchema(Schema):
- """
- This schema lists fields we require when scheduling storage assets.
- Some fields are not required, as they might live on the Sensor.attributes.
- You can use StorageScheduler.deserialize_flex_config to get that filled in.
- """
- soc_at_start = QuantityField(
- required=False,
- to_unit="MWh",
- default_src_unit="dimensionless", # placeholder, overridden in __init__
- return_magnitude=True,
- data_key="soc-at-start",
- )
- soc_min = QuantityField(
- validate=validate.Range(
- min=0
- ), # change to min=ur.Quantity("0 MWh") in case return_magnitude=False
- to_unit="MWh",
- default_src_unit="dimensionless", # placeholder, overridden in __init__
- return_magnitude=True,
- data_key="soc-min",
- )
- soc_max = QuantityField(
- to_unit="MWh",
- default_src_unit="dimensionless", # placeholder, overridden in __init__
- return_magnitude=True,
- data_key="soc-max",
- )
- power_capacity_in_mw = VariableQuantityField(
- "MW", required=False, data_key="power-capacity"
- )
- consumption_capacity = VariableQuantityField(
- "MW", data_key="consumption-capacity", required=False
- )
- production_capacity = VariableQuantityField(
- "MW", data_key="production-capacity", required=False
- )
- # Activation prices
- prefer_curtailing_later = fields.Bool(
- data_key="prefer-curtailing-later", load_default=True
- )
- prefer_charging_sooner = fields.Bool(
- data_key="prefer-charging-sooner", load_default=True
- )
- # Timezone placeholders for the soc_maxima, soc_minima and soc_targets fields are overridden in __init__
- soc_maxima = VariableQuantityField(
- to_unit="MWh",
- default_src_unit="dimensionless", # placeholder, overridden in __init__
- timezone="placeholder",
- data_key="soc-maxima",
- )
- soc_minima = VariableQuantityField(
- to_unit="MWh",
- default_src_unit="dimensionless", # placeholder, overridden in __init__
- timezone="placeholder",
- data_key="soc-minima",
- value_validator=validate.Range(min=0),
- )
- soc_targets = VariableQuantityField(
- to_unit="MWh",
- default_src_unit="dimensionless", # placeholder, overridden in __init__
- timezone="placeholder",
- data_key="soc-targets",
- )
- soc_unit = fields.Str(
- validate=OneOf(
- [
- "kWh",
- "MWh",
- ]
- ),
- data_key="soc-unit",
- required=False,
- )
- state_of_charge = VariableQuantityField(
- to_unit="MWh",
- data_key="state-of-charge",
- required=False,
- )
- charging_efficiency = VariableQuantityField(
- "%", data_key="charging-efficiency", required=False
- )
- discharging_efficiency = VariableQuantityField(
- "%", data_key="discharging-efficiency", required=False
- )
- roundtrip_efficiency = EfficiencyField(
- data_key="roundtrip-efficiency", required=False
- )
- storage_efficiency = VariableQuantityField("%", data_key="storage-efficiency")
- soc_gain = fields.List(
- VariableQuantityField("MW"),
- data_key="soc-gain",
- required=False,
- validate=validate.Length(min=1),
- )
- soc_usage = fields.List(
- VariableQuantityField("MW"),
- data_key="soc-usage",
- required=False,
- validate=validate.Length(min=1),
- )
- def __init__(
- self,
- start: datetime,
- sensor: Sensor,
- *args,
- default_soc_unit: str | None = None,
- **kwargs,
- ):
- """Pass the schedule's start, so we can use it to validate soc-target datetimes."""
- self.start = start
- self.sensor = sensor
- # guess default soc-unit
- if default_soc_unit is None:
- if self.sensor.unit in ("MWh", "kWh"):
- default_soc_unit = self.sensor.unit
- elif self.sensor.unit in ("MW", "kW"):
- default_soc_unit = self.sensor.unit + "h"
- self.soc_maxima = VariableQuantityField(
- to_unit="MWh",
- default_src_unit=default_soc_unit,
- timezone=sensor.timezone,
- data_key="soc-maxima",
- )
- self.soc_minima = VariableQuantityField(
- to_unit="MWh",
- default_src_unit=default_soc_unit,
- timezone=sensor.timezone,
- data_key="soc-minima",
- value_validator=validate.Range(min=0),
- )
- self.soc_targets = VariableQuantityField(
- to_unit="MWh",
- default_src_unit=default_soc_unit,
- timezone=sensor.timezone,
- 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)
- @validates_schema
- def check_whether_targets_exceed_max_planning_horizon(self, data: dict, **kwargs):
- 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):
- max_server_horizon *= self.sensor.event_resolution
- max_target_datetime = max([target["end"] for target in soc_targets])
- max_server_datetime = self.start + max_server_horizon
- if max_target_datetime > max_server_datetime:
- current_app.logger.warning(
- f"Target datetime exceeds {max_server_datetime}. Maximum scheduling horizon is {max_server_horizon}."
- )
- @validates("state_of_charge")
- def validate_state_of_charge_is_sensor(
- self, state_of_charge: Sensor | list[dict] | ur.Quantity
- ):
- if not isinstance(state_of_charge, Sensor):
- raise ValidationError(
- "The `state-of-charge` field can only be a Sensor. In the future, the state-of-charge field will replace soc-at-start field."
- )
- if state_of_charge.event_resolution != timedelta(0):
- raise ValidationError(
- "The field `state-of-charge` points to a sensor with a non-instantaneous event resolution. Please, use an instantaneous sensor."
- )
- @validates("storage_efficiency")
- def validate_storage_efficiency_resolution(self, unit: Sensor | ur.Quantity):
- if (
- isinstance(unit, Sensor)
- and unit.event_resolution != self.sensor.event_resolution
- ):
- raise ValidationError(
- "Event resolution of the storage efficiency and the power sensor don't match. Resampling the storage efficiency is not supported."
- )
- @validates_schema
- def check_redundant_efficiencies(self, data: dict, **kwargs):
- """
- Check that none of the following cases occurs:
- (1) flex-model contains both a round-trip efficiency and a charging efficiency
- (2) flex-model contains both a round-trip efficiency and a discharging efficiency
- (3) flex-model contains a round-trip efficiency, a charging efficiency and a discharging efficiency
- :raise: ValidationError
- """
- for field in ["charging_efficiency", "discharging_efficiency"]:
- if field in data and "roundtrip_efficiency" in data:
- raise ValidationError(
- f"Fields `{field}` and `roundtrip_efficiency` are mutually exclusive."
- )
- @post_load
- def post_load_sequence(self, data: dict, **kwargs) -> dict:
- """Perform some checks and corrections after we loaded."""
- # currently we only handle MWh internally, and the conversion to MWh happened during deserialization
- data["soc_unit"] = "MWh"
- # Convert efficiency to dimensionless (to the (0,1] range)
- if data.get("roundtrip_efficiency") is not None:
- data["roundtrip_efficiency"] = (
- data["roundtrip_efficiency"].to(ur.Quantity("dimensionless")).magnitude
- )
- return data
|