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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ New features

Infrastructure / Support
----------------------

* Price fields in the flex-context (including nested commitment prices, which are now also held to the flex-context's shared currency) are selected for currency validation by field type (``PriceField``) instead of by name suffix [see `PR #2311 <https://www.github.com/FlexMeasures/flexmeasures/pull/2311>`_]
* Upgraded dependencies [see `PR #1485 <https://www.github.com/FlexMeasures/flexmeasures/pull/1485>`_, `PR #2215 <https://www.github.com/FlexMeasures/flexmeasures/pull/2215>`_ and `PR #2243 <https://www.github.com/FlexMeasures/flexmeasures/pull/2243>`_]
* Prepare the ``device_scheduler`` to deal with commitments per device group [see `PR #1934 <https://www.github.com/FlexMeasures/flexmeasures/pull/1934>`_]
* Support storing encrypted connection secrets on organisations and assets, including utility functions, encryption key configuration, CLI commands to set and delete secrets, and UI tables that show stored secret names and optional expiration times without exposing their values [see `PR #2236 <https://www.github.com/FlexMeasures/flexmeasures/pull/2236>`_]
Expand Down
74 changes: 61 additions & 13 deletions flexmeasures/data/schemas/scheduling/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
SensorIdField,
SensorReference,
OutputSensorReferenceSchema,
PriceField,
)
from flexmeasures.data.schemas.scheduling import metadata
from flexmeasures.data.schemas.units import UnitField
Expand Down Expand Up @@ -72,8 +73,8 @@ def forbid_time_series_specs(self, data: dict, **kwargs):
class CommitmentSchema(Schema):
name = fields.Str(required=True, data_key="name")
baseline = VariableQuantityField("MW", required=False, data_key="baseline")
up_price = VariableQuantityField("/MW", required=False, data_key="up-price")
down_price = VariableQuantityField(
up_price = PriceField("/MW", required=False, data_key="up-price")
down_price = PriceField(
"/MW",
required=False,
data_key="down-price",
Expand Down Expand Up @@ -147,15 +148,15 @@ class DBCommitmentSchema(CommitmentSchema, NoTimeSeriesSpecs):
class SharedSchema(Schema):
"""Shared schema for fields common across commodities in flex-context and commodity-context."""

consumption_price = VariableQuantityField(
consumption_price = PriceField(
"/MWh",
required=False,
data_key="consumption-price",
return_magnitude=False,
metadata=metadata.CONSUMPTION_PRICE.to_dict(),
)

production_price = VariableQuantityField(
production_price = PriceField(
"/MWh",
required=False,
data_key="production-price",
Expand Down Expand Up @@ -187,15 +188,15 @@ class SharedSchema(Schema):
metadata=metadata.SITE_PRODUCTION_CAPACITY.to_dict(),
)

ems_consumption_breach_price = VariableQuantityField(
ems_consumption_breach_price = PriceField(
"/MW",
data_key="site-consumption-breach-price",
required=False,
value_validator=validate.Range(min=0),
metadata=metadata.SITE_CONSUMPTION_BREACH_PRICE.to_dict(),
)

ems_production_breach_price = VariableQuantityField(
ems_production_breach_price = PriceField(
"/MW",
data_key="site-production-breach-price",
required=False,
Expand All @@ -212,7 +213,7 @@ class SharedSchema(Schema):
metadata=metadata.SITE_PEAK_CONSUMPTION.to_dict(),
)

ems_peak_consumption_price = VariableQuantityField(
ems_peak_consumption_price = PriceField(
"/MW",
data_key="site-peak-consumption-price",
required=False,
Expand All @@ -229,7 +230,7 @@ class SharedSchema(Schema):
metadata=metadata.SITE_PEAK_PRODUCTION.to_dict(),
)

ems_peak_production_price = VariableQuantityField(
ems_peak_production_price = PriceField(
"/MW",
data_key="site-peak-production-price",
required=False,
Expand All @@ -238,28 +239,28 @@ class SharedSchema(Schema):
)

# Breach prices for device capacity constraints
consumption_breach_price = VariableQuantityField(
consumption_breach_price = PriceField(
"/MW",
data_key="consumption-breach-price",
required=False,
value_validator=validate.Range(min=0),
metadata=metadata.CONSUMPTION_BREACH_PRICE.to_dict(),
)
production_breach_price = VariableQuantityField(
production_breach_price = PriceField(
"/MW",
data_key="production-breach-price",
required=False,
value_validator=validate.Range(min=0),
metadata=metadata.PRODUCTION_BREACH_PRICE.to_dict(),
)
soc_minima_breach_price = VariableQuantityField(
soc_minima_breach_price = PriceField(
"/MWh",
data_key="soc-minima-breach-price",
required=False,
value_validator=validate.Range(min=0),
metadata=metadata.SOC_MINIMA_BREACH_PRICE.to_dict(),
)
soc_maxima_breach_price = VariableQuantityField(
soc_maxima_breach_price = PriceField(
"/MWh",
data_key="soc-maxima-breach-price",
required=False,
Expand Down Expand Up @@ -340,7 +341,7 @@ def _try_to_convert_price_units(self, data: dict, original_data: dict, **kwargs)
shared_currency_unit = None
previous_field_name = None
for field in self.declared_fields:
if field[-5:] == "price" and field in data:
if isinstance(self.declared_fields[field], PriceField) and field in data:
price_field = self.declared_fields[field]
price_unit = price_field._get_unit(data[field])
currency_unit = str(
Expand All @@ -363,6 +364,13 @@ def _try_to_convert_price_units(self, data: dict, original_data: dict, **kwargs)
if shared_currency_unit not in price_unit:
error_message += f" Also note that all prices in the flex-context must share the same currency unit (in this case: '{shared_currency_unit}')."
raise ValidationError(error_message, field_name=field_name)
# Also hold the nested commitment prices to the shared currency
shared_currency_unit, previous_field_name = (
self._validate_commitment_price_units(
data, shared_currency_unit, previous_field_name
)
)

if shared_currency_unit is not None:
data["shared_currency_unit"] = shared_currency_unit
elif sensor := data.get("consumption_price_sensor"):
Expand All @@ -373,6 +381,46 @@ def _try_to_convert_price_units(self, data: dict, original_data: dict, **kwargs)
data["shared_currency_unit"] = "EUR"
return data

def _validate_commitment_price_units(
self,
data: dict,
shared_currency_unit: str | None,
previous_field_name: str | None,
) -> tuple[str | None, str | None]:
"""Hold the nested commitment prices to the shared currency."""
for commitment in data.get("commitments", []):
for field, price_field in CommitmentSchema._declared_fields.items():
if not isinstance(price_field, PriceField) or field not in commitment:
continue
price_unit = price_field._get_unit(commitment[field])
currency_unit = self._extract_currency_unit(price_unit)
if shared_currency_unit is None:
shared_currency_unit = str(
ur.Quantity(currency_unit).to_base_units().units
)
previous_field_name = price_field.data_key
if not units_are_convertible(currency_unit, shared_currency_unit):
field_name = price_field.data_key
error_message = f"Invalid unit. A valid unit would be, for example, '{shared_currency_unit}/MWh' (this example uses '{shared_currency_unit}', because '{previous_field_name}' used that currency). However, you passed an incompatible price ('{price_unit}') for the '{field_name}' field of commitment '{commitment.get('name')}'. Also note that all prices in the flex-context must share the same currency unit (in this case: '{shared_currency_unit}')."
raise ValidationError(error_message, field_name="commitments")
return shared_currency_unit, previous_field_name

@staticmethod
def _extract_currency_unit(price_unit: str) -> str:
"""Obtain the currency part of a price unit, whose denominator may vary.

>>> FlexContextSchema()._extract_currency_unit("EUR/MWh")
'EUR'
>>> FlexContextSchema()._extract_currency_unit("USD/MW")
'USD'
"""
q = ur.Quantity(f"1 {price_unit}")
for denominator in ("MWh", "MW"):
candidate = str((q * ur.Quantity(f"1 {denominator}")).to_base_units().units)
if is_currency_unit(candidate):
return candidate
return str(q.units)

@staticmethod
def _to_currency_per_mwh(price_unit: str) -> str:
"""Convert a price unit to a base currency used to express that price per MWh.
Expand Down
12 changes: 12 additions & 0 deletions flexmeasures/data/schemas/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,18 @@ def _get_unit(
return unit


class PriceField(VariableQuantityField):
"""VariableQuantityField for monetary values.

Price fields participate in currency validation: all price fields in the
flex-context must share one currency (recorded as the flex-context's
``shared_currency_unit``), and price fields in a flex-model must use a
currency that is convertible to the flex-context's shared currency.
"""

pass


class RepurposeValidatorToIgnoreSensorsAndLists(validate.Validator):
"""Validator that executes another validator (the one you initialize it with) only on non-Sensor and non-list values."""

Expand Down
47 changes: 47 additions & 0 deletions flexmeasures/data/schemas/tests/test_scheduling.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,53 @@ def load_schema():
},
{"commitments.0.baseline": "Cannot convert value `10 kWh` to 'MW'"},
),
# Commitment prices must share the flex-context's currency
(
{
"consumption-price": "100 EUR/MWh",
"commitments": [
{
"name": "a sample commitment",
"baseline": "10 kW",
"up-price": "100 USD/MWh",
}
],
},
{
"commitments": "all prices in the flex-context must share the same currency unit"
},
),
# Commitment prices sharing the flex-context's currency are fine
(
{
"consumption-price": "100 EUR/MWh",
"commitments": [
{
"name": "a sample commitment",
"baseline": "10 kW",
"up-price": "100 EUR/MWh",
"down-price": "0.12 EUR/kWh",
}
],
},
False,
),
# Commitments can also set the shared currency (mixed currencies still fail)
(
{
"commitments": [
{
"name": "a sample commitment",
"baseline": "10 kW",
"up-price": "100 USD/MWh",
"down-price": "120 EUR/MWh",
}
]
},
{
"commitments": "all prices in the flex-context must share the same currency unit"
},
),
# Energy price units with a power baseline
(
{
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/ui/static/openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"termsOfService": null,
"title": "FlexMeasures",
"version": "0.33.2"
"version": "1.0.0"
},
"externalDocs": {
"description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.",
Expand Down
Loading