diff --git a/.claude/agents/api-backward-compatibility-specialist.md b/.claude/agents/api-backward-compatibility-specialist.md index ab3aac110b..71b93448e6 100644 --- a/.claude/agents/api-backward-compatibility-specialist.md +++ b/.claude/agents/api-backward-compatibility-specialist.md @@ -1,6 +1,7 @@ --- name: api-backward-compatibility-specialist description: Protects users and integrators by ensuring API changes are backwards compatible, properly versioned, and well-documented +model: sonnet --- # Agent: API & Backward Compatibility Specialist diff --git a/.claude/agents/architecture-domain-specialist.md b/.claude/agents/architecture-domain-specialist.md index 4cbdad7a06..8654d89815 100644 --- a/.claude/agents/architecture-domain-specialist.md +++ b/.claude/agents/architecture-domain-specialist.md @@ -1,6 +1,7 @@ --- name: architecture-domain-specialist description: Guards domain model, invariants, and architecture to maintain model clarity and prevent erosion of core principles +model: opus --- # Agent: Architecture & Domain Specialist diff --git a/.claude/agents/coordinator.md b/.claude/agents/coordinator.md index 9077ea39d4..0ad4ce5e41 100644 --- a/.claude/agents/coordinator.md +++ b/.claude/agents/coordinator.md @@ -1,6 +1,7 @@ --- name: coordinator description: Meta-agent that manages agent lifecycle, enforces structural standards, and maintains coherence across the agent system +model: sonnet --- # Agent: Coordinator diff --git a/.claude/agents/data-time-semantics-specialist.md b/.claude/agents/data-time-semantics-specialist.md index b01e477c4a..90aad5ed70 100644 --- a/.claude/agents/data-time-semantics-specialist.md +++ b/.claude/agents/data-time-semantics-specialist.md @@ -1,6 +1,7 @@ --- name: data-time-semantics-specialist description: Prevents subtle bugs in time handling, units, and data semantics with focus on timezone-aware operations and unit conversions +model: sonnet --- # Agent: Data & Time Semantics Specialist diff --git a/.claude/agents/documentation-developer-experience-specialist.md b/.claude/agents/documentation-developer-experience-specialist.md index 9d0831d298..615bb3e1ea 100644 --- a/.claude/agents/documentation-developer-experience-specialist.md +++ b/.claude/agents/documentation-developer-experience-specialist.md @@ -1,6 +1,7 @@ --- name: documentation-developer-experience-specialist description: Ensures excellent documentation, clear error messages, and smooth developer workflows to keep FlexMeasures accessible +model: sonnet --- # Agent: Documentation & Developer Experience Specialist diff --git a/.claude/agents/performance-scalability-specialist.md b/.claude/agents/performance-scalability-specialist.md index 9786881e2f..605d7993fb 100644 --- a/.claude/agents/performance-scalability-specialist.md +++ b/.claude/agents/performance-scalability-specialist.md @@ -1,6 +1,7 @@ --- name: performance-scalability-specialist description: Identifies performance bottlenecks, inefficient algorithms, and scalability issues to keep FlexMeasures fast under load +model: sonnet --- # Agent: Performance & Scalability Specialist diff --git a/.claude/agents/test-specialist.md b/.claude/agents/test-specialist.md index 39fcafcadf..e00364ff6e 100644 --- a/.claude/agents/test-specialist.md +++ b/.claude/agents/test-specialist.md @@ -1,6 +1,7 @@ --- name: test-specialist description: Focuses on test coverage, quality, and testing best practices without modifying production code +model: sonnet --- # Agent: Test Specialist diff --git a/.claude/agents/tooling-ci-specialist.md b/.claude/agents/tooling-ci-specialist.md index 02bf4b0620..bace95aadf 100644 --- a/.claude/agents/tooling-ci-specialist.md +++ b/.claude/agents/tooling-ci-specialist.md @@ -1,6 +1,7 @@ --- name: tooling-ci-specialist description: Reviews GitHub Actions workflows, pre-commit hooks, and CI/CD pipelines to ensure automation reliability +model: sonnet --- # Agent: Tooling & CI Specialist diff --git a/.claude/agents/ui-specialist.md b/.claude/agents/ui-specialist.md index a985cf6f09..725673a7ea 100644 --- a/.claude/agents/ui-specialist.md +++ b/.claude/agents/ui-specialist.md @@ -1,6 +1,7 @@ --- name: ui-specialist description: Guards UI consistency, permission patterns, JavaScript interaction patterns, and template quality in the FlexMeasures web interface +model: sonnet --- # Agent: UI Specialist diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 4fc2f13886..fba88b9a65 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -18,7 +18,7 @@ New features * Sensor references in flex-model and flex-context support various ways of filtering by source [see `PR #2209 `_] * 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 `_] * Support multiple feeders to a shared storage [see `PR #2001 `_ ] -* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_ and `PR #2271 `_] +* The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_, `PR #2271 `_ and `PR #2272 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_] diff --git a/documentation/features/scheduling.rst b/documentation/features/scheduling.rst index aec2ce3397..77831da391 100644 --- a/documentation/features/scheduling.rst +++ b/documentation/features/scheduling.rst @@ -149,6 +149,32 @@ And if the asset belongs to a larger system (a hierarchy of assets), the schedul The flexible device can still have its own power limit defined in its flex-model. +.. _commodity_context_defaults: + +Smart defaults for commodity-context grid connections +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +For multi-commodity scheduling problems, each entry of the top-level ``commodities`` list is itself a flex-context (a "commodity context") describing the grid connection for that commodity. +A commodity context that leaves out some or all of its grid-connection fields (``consumption-price``, ``production-price``, ``site-consumption-capacity``, ``site-production-capacity`` and ``site-power-capacity``) gets sensible defaults for the missing fields, rather than failing or silently leaving the grid unconstrained. + +As a rule of thumb, a price given for a direction (consumption or production) implies a grid connection in that direction, with an unlimited capacity unless a capacity is also given; a capacity given for a direction (without a price) implies a 0 price in that direction; and anything not implied by a given field defaults to "no connection" (0 capacity, as a soft constraint). +The exception is ``site-power-capacity`` given on its own, which sets a *hard* (symmetric) capacity limit instead. + +This leads to the following defaults, depending on which fields are explicitly given: + +- **Nothing given** (e.g. just ``{"commodity": "gas"}``): both ``site-consumption-capacity`` and ``site-production-capacity`` default to 0, as soft constraints (a breach is possible, but penalized). ``site-power-capacity`` stays unlimited. +- **Only** ``consumption-price``: ``site-power-capacity`` and ``site-consumption-capacity`` stay unlimited; ``site-production-capacity`` defaults to 0 (soft). +- **Only** ``production-price``: the mirror image, for production. +- **Only** ``site-consumption-capacity``: ``site-power-capacity`` stays unlimited; ``consumption-price`` defaults to 0; ``site-production-capacity`` (and, transitively, ``production-price``) default to 0. +- **Only** ``site-production-capacity``: the mirror image, for production. +- **Only** ``site-power-capacity``: a *hard* constraint at that capacity, with ``site-consumption-capacity`` and ``site-production-capacity`` both set equal to it, and ``consumption-price``/``production-price`` defaulting to 0. + +For any combination of explicitly given fields, these rules apply per direction (consumption/production) independently, filling in only the fields not already determined by a given field. +As a safety net, ``consumption-price`` still defaults to 0 if it remains unset after applying the rules above, since the scheduler requires a resolvable consumption price. + +.. note:: Setting ``relax-constraints`` to ``False`` on a commodity context that ends up with a smart-defaulted 0 hard capacity can make the schedule infeasible; FlexMeasures logs a warning in that case. + + .. _flex_models_and_schedulers: The flex-models & corresponding schedulers diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index dc0c02b7f7..5dbb3a966f 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -35,6 +35,7 @@ CommodityFlexContextSchema, FlexContextSchema, MultiSensorFlexModelSchema, + SharedSchema, ) from flexmeasures.data.schemas.sensors import SensorReference, VariableQuantityField from flexmeasures.data.services.scheduling_result import SchedulingJobResult @@ -1351,6 +1352,7 @@ def convert_to_commitments( start, end, timing_kwargs["resolution"] ) commitment_commodity = commitment_spec.get("commodity", "electricity") + bound_device_count = 0 for d, flex_model_d in enumerate(flex_model): device_commodity = flex_model_d.get("commodity", "electricity") if device_commodity != commitment_commodity: @@ -1361,6 +1363,15 @@ def convert_to_commitments( **commitment_spec, ) commitments.append(commitment) + bound_device_count += 1 + if bound_device_count == 0: + current_app.logger.warning( + f"Commitment '{commitment_spec.get('name')}' has commodity" + f" '{commitment_commodity}', which matches none of the devices" + " in the flex-model. This commitment will not bind any device" + " (check for a typo in the commitment's `commodity` field, or in" + " a device's `commodity` field in the flex-model)." + ) return commitments @@ -1408,9 +1419,17 @@ def _deserialize_flex_context(self): commodity_flex_context ) - # Ensure all flex-contexts share the same currency unit + # Ensure all flex-contexts share the same currency unit. Contexts with + # no user-given price fields at all (shared_currency_unit_is_default) + # only carry a fallback "EUR" currency, which isn't a real constraint, + # so they're skipped here and instead backfilled below, once a real + # portfolio currency is known. shared_currency_unit = None + default_currency_contexts = [] for commodity_flex_context in self.flex_context: + if commodity_flex_context.get("shared_currency_unit_is_default"): + default_currency_contexts.append(commodity_flex_context) + continue context_currency_unit = commodity_flex_context["shared_currency_unit"] if shared_currency_unit is None: shared_currency_unit = context_currency_unit @@ -1421,6 +1440,20 @@ def _deserialize_flex_context(self): f"All prices in the flex-context must share the same currency unit (in this case: '{shared_currency_unit}')." ) + # Let price-free contexts inherit the portfolio's actual currency, + # where determinable (i.e. when at least one other context set one). + if shared_currency_unit is not None: + for commodity_flex_context in default_currency_contexts: + SharedSchema._rebase_default_context_currency( + commodity_flex_context, shared_currency_unit + ) + elif default_currency_contexts: + # No context anywhere gave an explicit price: fall back to the + # (shared) default currency already stamped on each of them. + shared_currency_unit = default_currency_contexts[0][ + "shared_currency_unit" + ] + # Nest the flex-contexts per commodity under the commodity_contexts field self.flex_context = dict( commodity_contexts=self.flex_context, diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 4275892c53..9668fcf6f7 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -17,6 +17,7 @@ from flexmeasures.data.models.planning.linear_optimization import device_scheduler from flexmeasures.data.models.generic_assets import GenericAsset, GenericAssetType from flexmeasures.data.utils import save_to_db +from flexmeasures.utils.unit_utils import ur def test_multi_feed_device_scheduler_shared_buffer(): @@ -1782,3 +1783,63 @@ def test_electricity_device_indices_exclude_other_commodities(): assert mapping["electricity"] == [0, 2, 3, 4] assert mapping["gas"] == [1, 5] assert scheduler._electricity_device_indices() == [0, 2, 3, 4] + + +def test_commitment_commodity_does_not_bind_other_commodity_devices(): + """test_commitment_commodity_does_not_bind_other_commodity_devices: a commitment + listed under the flex-context's `commitments` should only bind devices of its own + `commodity` (defaulting to "electricity", like devices do). A gas commitment + should therefore not create a FlowCommitment against an electricity device, and + vice versa. + + This is a DB-free, unit-level test of StorageScheduler.convert_to_commitments. + """ + scheduler = object.__new__(StorageScheduler) + scheduler.flex_context = { + "shared_currency_unit": "EUR", + "commitments": [ + { + "name": "gas commitment", + "commodity": "gas", + "baseline": ur.Quantity("1 MW"), + }, + { + # No `commodity` given: defaults to "electricity", like devices do. + "name": "electricity commitment", + "baseline": ur.Quantity("2 MW"), + }, + ], + } + # Flexible devices: 0 = electricity, 1 = gas. + flex_model = [ + {"commodity": "electricity"}, + {"commodity": "gas"}, + ] + + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-01T03:00:00+01:00") + resolution = pd.Timedelta("1h") + + commitments = scheduler.convert_to_commitments( + flex_model=flex_model, + query_window=(start, end), + resolution=resolution, + beliefs_before=None, + ) + + assert len(commitments) == 2 + + gas_commitment = next(c for c in commitments if c.name == "gas commitment") + electricity_commitment = next( + c for c in commitments if c.name == "electricity commitment" + ) + + # The gas commitment binds only the gas device (index 1), not the electricity + # device (index 0). + assert (gas_commitment.device == 1).all() + assert set(gas_commitment.device_group.unique()) == {"gas"} + + # The electricity commitment (commodity defaulting to "electricity") binds only + # the electricity device (index 0), not the gas device (index 1). + assert (electricity_commitment.device == 0).all() + assert set(electricity_commitment.device_group.unique()) == {"electricity"} diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 93c86ac555..cf15dda15c 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -4,6 +4,8 @@ from datetime import timedelta from typing import Any, Callable, Dict +from flask import current_app + from marshmallow import ( Schema, fields, @@ -71,6 +73,18 @@ def forbid_time_series_specs(self, data: dict, **kwargs): class CommitmentSchema(Schema): name = fields.Str(required=True, data_key="name") + # Undocumented for now (not part of UI_FLEX_CONTEXT_SCHEMA, OpenAPI or Sphinx docs). + # Internal bookkeeping only: not the documented way to associate a commitment + # with a commodity. API users should instead place the commitment under the + # relevant entry of the multi-commodity `commodities` list (one flex-context + # per commodity) -- see StorageScheduler.convert_to_commitments, which matches + # this field against each device's own `commodity`, defaulting to + # "electricity" as well. + commodity = fields.Str( + required=False, + load_default="electricity", + data_key="commodity", + ) baseline = VariableQuantityField("MW", required=False, data_key="baseline") up_price = VariableQuantityField("/MW", required=False, data_key="up-price") down_price = VariableQuantityField( @@ -370,9 +384,57 @@ def _try_to_convert_price_units(self, data: dict, original_data: dict, **kwargs) elif sensor := data.get("production_price_sensor"): data["shared_currency_unit"] = self._to_currency_per_mwh(sensor.unit) else: + # No user-given price fields at all: fall back to "EUR", but flag this + # as a default (not user-given), so cross-context/cross-schema currency + # comparisons can skip it (a price-free context should never trip a + # currency-mismatch error against the rest of a differently-currencied + # portfolio; see CommodityFlexContextSchema.fill_grid_connection_defaults + # and FlexContextSchema.validate_commodity_contexts_shared_currency). data["shared_currency_unit"] = "EUR" + data["shared_currency_unit_is_default"] = True return data + # Currency-denominated fields that CommodityFlexContextSchema's smart defaults + # (fill_grid_connection_defaults) may fill with a fallback "EUR" price/breach + # price when a context has no user-given price fields at all. + _CURRENCY_DENOMINATED_FIELDS = ( + "consumption_price", + "production_price", + "ems_consumption_breach_price", + "ems_production_breach_price", + "consumption_breach_price", + "production_breach_price", + "soc_minima_breach_price", + "soc_maxima_breach_price", + "ems_peak_consumption_price", + "ems_peak_production_price", + ) + + @classmethod + def _rebase_default_context_currency(cls, context: dict, new_currency: str): + """Re-express a price-free context's fallback-currency fields in another currency. + + Only called for a commodity context that had no user-given price fields + (``shared_currency_unit_is_default`` is True), once a real portfolio + currency becomes known (e.g. from the top-level flex-context, or from a + sibling commodity context). All of that context's currency-denominated + fields were filled with plain quantities in a fallback "EUR", so their + magnitudes carry over unchanged under the new currency label (no FX + conversion is implied or attempted). + """ + for field in cls._CURRENCY_DENOMINATED_FIELDS: + value = context.get(field) + if not isinstance(value, ur.Quantity): + continue + old_units = str(value.units) + denominator = old_units.split("/", 1)[1] if "/" in old_units else None + new_unit = ( + new_currency if denominator is None else f"{new_currency}/{denominator}" + ) + context[field] = ur.Quantity(value.magnitude, new_unit) + context["shared_currency_unit"] = new_currency + context["shared_currency_unit_is_default"] = False + @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. @@ -402,6 +464,165 @@ def __init__(self, *args, **kwargs): [("commodity", commodity_field), *self.fields.items()] ) + @post_load(pass_original=True) + def fill_grid_connection_defaults(self, data: dict, original_data: dict, **kwargs): + """Fill in smarter defaults for a commodity context's grid-connection fields. + + A commodity context (an entry of the top-level `commodities` list) may omit + some or all of the grid-connection fields (`consumption-price`, + `production-price`, `site-consumption-capacity`, `site-production-capacity`, + `site-power-capacity`). Rather than leaving those simply unset (which, for + `consumption-price`, would make the scheduler fail, since it requires one), + we derive sensible defaults from *which* of those five fields were explicitly + given (inspecting the original input, not post-default-fill presence). + + A price given for a direction (consumption or production) implies a grid + connection in that direction, with an unlimited capacity unless a capacity + is also given; a capacity given for a direction (without a price) implies a + 0 price in that direction; and anything not implied by a given field + defaults to "no connection" (0 capacity, as a soft constraint). The + exception is `site-power-capacity` given on its own, which sets a *hard* + (symmetric) capacity limit instead. See :ref:`commodity_context_defaults` + for the full user-facing explanation, including worked examples. + + Precedence (single-field triggers): + + 1. None of the five given (e.g. just `{"commodity": "gas"}`): no grid + connection at all. `site-consumption-capacity` and + `site-production-capacity` default to 0, as *soft* constraints (a default + breach price is filled in, so breaching is possible but penalized -- this + relies on `relax-constraints`/`relax-site-capacity-constraints`, which + default to True). `site-power-capacity` is left unlimited (unset). + 2. Only `consumption-price` given: assume a grid connection for consumption. + `site-power-capacity` and `site-consumption-capacity` stay unlimited. + `site-production-capacity` defaults to 0 (soft). + 3. Only `production-price` given: the mirror image of (2), for production. + 4. Only `site-consumption-capacity` given: `site-power-capacity` stays + unlimited; `consumption-price` defaults to 0; `site-production-capacity` + (and, transitively, `production-price`) default to 0. + 5. Only `site-production-capacity` given: the mirror image of (4). + 6. Only `site-power-capacity` given: a *hard* constraint at that capacity. + `site-consumption-capacity` and `site-production-capacity` are both set + equal to it (no breach price is filled in, so the constraint stays hard); + `consumption-price` and `production-price` default to 0. + + As a safety net (since the scheduler requires a resolvable consumption + price), `consumption-price` defaults to 0 if still unset after applying the + rules above (`production-price` already falls back to `consumption-price` + at the scheduler level, so no separate safety net is needed for it). + + A commodity context with no user-given price fields does not trip a spurious cross-currency error against a differently-currencied portfolio; + its 0-price/breach-price fields instead inherit the portfolio's real currency where determinable (from a top-level price or a sibling commodity context). + """ + + has_consumption_price = "consumption-price" in original_data + has_production_price = "production-price" in original_data + has_consumption_capacity = "site-consumption-capacity" in original_data + has_production_capacity = "site-production-capacity" in original_data + has_power_capacity = "site-power-capacity" in original_data + + any_given = ( + has_consumption_price + or has_production_price + or has_consumption_capacity + or has_production_capacity + or has_power_capacity + ) + + currency = data.get("shared_currency_unit") or "EUR" + zero_price = ur.Quantity(f"0 {currency}/MWh") + zero_capacity = ur.Quantity("0 MW") + + # Case 6: site-power-capacity is the only field given -> hard constraint. + if has_power_capacity and not ( + has_consumption_price + or has_production_price + or has_consumption_capacity + or has_production_capacity + ): + power_capacity = data["ems_power_capacity_in_mw"] + data.setdefault("ems_consumption_capacity_in_mw", power_capacity) + data.setdefault("ems_production_capacity_in_mw", power_capacity) + data.setdefault("consumption_price", zero_price) + data.setdefault("production_price", zero_price) + return data + + # Case 1: nothing given at all -> fully disconnected commodity. + if not any_given: + self._default_zero_capacity_as_soft_constraint( + data, "ems_consumption_capacity_in_mw", zero_capacity + ) + self._default_zero_capacity_as_soft_constraint( + data, "ems_production_capacity_in_mw", zero_capacity + ) + data.setdefault("consumption_price", zero_price) + return data + + # Cases 2-5 and combinations thereof: fill in what's still missing, per + # direction (consumption/production), independently. + if not has_consumption_price and not has_consumption_capacity: + self._default_zero_capacity_as_soft_constraint( + data, "ems_consumption_capacity_in_mw", zero_capacity + ) + if has_consumption_capacity and not has_consumption_price: + data.setdefault("consumption_price", zero_price) + + if not has_production_price and not has_production_capacity: + self._default_zero_capacity_as_soft_constraint( + data, "ems_production_capacity_in_mw", zero_capacity + ) + if has_production_capacity and not has_production_price: + data.setdefault("production_price", zero_price) + + # Safety net: the scheduler requires a resolvable consumption price. + data.setdefault("consumption_price", zero_price) + + return data + + def _default_zero_capacity_as_soft_constraint( + self, data: dict, field: str, zero_capacity: ur.Quantity + ): + """Default a site capacity field to 0, as a *soft* constraint. + + Also fills in a default breach price for that direction (unless one was + already set), so the 0 capacity is enforced as a soft constraint (breaching + is possible, but penalized) rather than a hard, potentially infeasible, one. + This mirrors FlexContextSchema.check_prices, but scoped to a single + commodity context, and only fired for capacities defaulted here (not for + capacities the caller explicitly set to 0). + """ + if field in data: + # Already set (e.g. by an earlier rule in this method); leave it as-is. + return + data[field] = zero_capacity + + breach_price_field = { + "ems_consumption_capacity_in_mw": "ems_consumption_breach_price", + "ems_production_capacity_in_mw": "ems_production_breach_price", + }[field] + if data.get("relax_site_capacity_constraints") or data.get("relax_constraints"): + if not data.get(breach_price_field): + currency = data.get("shared_currency_unit") or "EUR" + shared_currency = ur.Quantity(currency) + self.set_default_breach_prices( + data, + fields=[breach_price_field], + price=10000 * shared_currency / ur.Quantity("kW"), + ) + elif data.get("relax_constraints") is False: + # relax-constraints defaults to True, so False here can only be an + # explicit user choice. Since relax-site-capacity-constraints is also + # not set/true, this 0 capacity ends up as a *hard* constraint, which + # is likely infeasible for any commodity with actual devices/flow. + current_app.logger.warning( + f"Commodity context '{data.get('commodity', 'electricity')}' has" + f" its '{field}' defaulted to a 0 capacity, but" + " 'relax-constraints' was explicitly set to False (and" + " 'relax-site-capacity-constraints' was not set to True), so this" + " ends up as a hard 0-capacity constraint, which is likely" + " infeasible." + ) + class FlexContextSchema(SharedSchema): """This schema defines fields that provide context to the portfolio to be optimized.""" @@ -496,6 +717,11 @@ def validate_commodity_contexts_shared_currency( shared_currency_unit = None for context in commodity_contexts: + if context.get("shared_currency_unit_is_default"): + # No user-given price fields in this context: its "EUR" currency is + # just a fallback, not a real constraint, so don't let it clash with + # a differently-currencied portfolio. + continue context_currency_unit = context.get("shared_currency_unit") if context_currency_unit is None: continue @@ -516,25 +742,44 @@ def validate_commodity_contexts_shared_currency( # serve as the electricity context only when the commodities list has no # electricity entry (see _get_commodity_contexts in storage.py). - @validates_schema(pass_original=True) - def check_prices(self, data: dict, original_data: dict, **kwargs): - """Check assumptions about prices. + def _reconcile_commodity_context_currencies(self, data: dict) -> str: + """Backfill price-free contexts' currency with the portfolio's real currency. - 1. The flex-context must contain at most 1 consumption price and at most 1 production price field. - 2. All prices must share the same currency. + Determines the portfolio's real (user-given) shared currency, if any: the + top-level one, unless it's itself just a fallback (no user-given price + fields at the top level), in which case falls back to the first + non-default commodity context's currency, if any. Then rebases any + price-free ("default currency") commodity context onto that real currency, + so their 0-price/breach-price fills inherit it. Returns the (possibly + just-updated) top-level `shared_currency_unit`. """ + commodity_contexts = data.get("commodity_contexts", []) or [] + real_shared_currency_unit = None + if not data.get("shared_currency_unit_is_default"): + real_shared_currency_unit = data["shared_currency_unit"] + else: + for context in commodity_contexts: + if not context.get("shared_currency_unit_is_default"): + real_shared_currency_unit = context["shared_currency_unit"] + break - # The flex-context must contain at most 1 consumption price and at most 1 production price field - if "consumption_price_sensor" in data and "consumption_price" in data: - raise ValidationError( - "Must pass either consumption-price or consumption-price-sensor." - ) - if "production_price_sensor" in data and "production_price" in data: - raise ValidationError( - "Must pass either production-price or production-price-sensor." - ) + if real_shared_currency_unit is not None and data.get( + "shared_currency_unit_is_default" + ): + data["shared_currency_unit"] = real_shared_currency_unit + data["shared_currency_unit_is_default"] = False + + if real_shared_currency_unit is not None: + for context in commodity_contexts: + if context.get("shared_currency_unit_is_default"): + self._rebase_default_context_currency( + context, real_shared_currency_unit + ) + + return data["shared_currency_unit"] - # New price fields can only be used after updating to the new consumption-price and production-price fields + def _check_deprecated_price_sensor_migration(self, data: dict, original_data: dict): + """New price fields can only be used after updating to consumption-price/production-price.""" field_map = { field.data_key: field_var for field_var, field in self.declared_fields.items() @@ -567,14 +812,41 @@ def check_prices(self, data: dict, original_data: dict, **kwargs): f"""Please switch to using `production-price: {{"sensor": {data[field_map["production-price-sensor"]].id}}}`.""" ) + @validates_schema(pass_original=True) + def check_prices(self, data: dict, original_data: dict, **kwargs): + """Check assumptions about prices. + + 1. The flex-context must contain at most 1 consumption price and at most 1 production price field. + 2. All prices must share the same currency. + """ + + # The flex-context must contain at most 1 consumption price and at most 1 production price field + if "consumption_price_sensor" in data and "consumption_price" in data: + raise ValidationError( + "Must pass either consumption-price or consumption-price-sensor." + ) + if "production_price_sensor" in data and "production_price" in data: + raise ValidationError( + "Must pass either production-price or production-price-sensor." + ) + + self._check_deprecated_price_sensor_migration(data, original_data) + # make sure that the prices fields are valid price units # All prices must share the same unit data = self._try_to_convert_price_units(data, original_data) - shared_currency = ur.Quantity(data["shared_currency_unit"]) + shared_currency = ur.Quantity( + self._reconcile_commodity_context_currencies(data) + ) # Also check that top-level prices share their currency with any per-commodity contexts for context in data.get("commodity_contexts", []) or []: + if context.get("shared_currency_unit_is_default"): + # Already reconciled (or left as a harmless fallback, if no real + # currency was determinable anywhere) by + # _reconcile_commodity_context_currencies above. + continue context_currency_unit = context.get("shared_currency_unit") if context_currency_unit is None: continue diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index 90842d2cf9..ed94d711a6 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -16,6 +16,7 @@ ) from flexmeasures.data.models.time_series import Sensor from flexmeasures.data.schemas.sensors import TimedEventSchema, VariableQuantityField +from flexmeasures.utils.unit_utils import ur @pytest.mark.parametrize( @@ -1053,6 +1054,150 @@ def test_commodity_flex_context_defaults( assert loaded.get("relax_constraints", True) is True +def _assert_quantity_or_none(actual, expected): + """Compare an (optionally None) ur.Quantity against an expected ur.Quantity or None.""" + if expected is None: + assert actual is None + else: + assert actual is not None + assert actual.to(expected.units).magnitude == pytest.approx(expected.magnitude) + + +@pytest.mark.parametrize( + ["context_input", "expected"], + [ + # Case 1: none of the 5 grid-connection fields given -> fully disconnected + # commodity. Both site capacities default to 0 as *soft* constraints (a + # default breach price is filled in); site-power-capacity stays unlimited. + ( + {"commodity": "gas"}, + { + "ems_consumption_capacity_in_mw": ur.Quantity("0 MW"), + "ems_production_capacity_in_mw": ur.Quantity("0 MW"), + "ems_power_capacity_in_mw": None, + "consumption_price": ur.Quantity("0 EUR/MWh"), + "ems_consumption_breach_price_set": True, + "ems_production_breach_price_set": True, + }, + ), + # Case 2: only consumption-price given -> assume a grid connection for + # consumption (unlimited site-power/consumption-capacity); 0 + # site-production-capacity (soft). + ( + {"commodity": "gas", "consumption-price": "10 EUR/MWh"}, + { + "ems_consumption_capacity_in_mw": None, + "ems_production_capacity_in_mw": ur.Quantity("0 MW"), + "ems_power_capacity_in_mw": None, + "consumption_price": ur.Quantity("10 EUR/MWh"), + "ems_production_breach_price_set": True, + }, + ), + # Case 3: only production-price given -> mirror image of case 2. + ( + {"commodity": "gas", "production-price": "10 EUR/MWh"}, + { + "ems_consumption_capacity_in_mw": ur.Quantity("0 MW"), + "ems_production_capacity_in_mw": None, + "ems_power_capacity_in_mw": None, + "consumption_price": ur.Quantity("0 EUR/MWh"), + "production_price": ur.Quantity("10 EUR/MWh"), + "ems_consumption_breach_price_set": True, + }, + ), + # Case 4: only site-consumption-capacity given -> unlimited + # site-power-capacity, 0 consumption-price, 0 site-production-capacity + # (soft), (and thereby 0 production-price). + ( + {"commodity": "gas", "site-consumption-capacity": "5 MW"}, + { + "ems_consumption_capacity_in_mw": ur.Quantity("5 MW"), + "ems_production_capacity_in_mw": ur.Quantity("0 MW"), + "ems_power_capacity_in_mw": None, + "consumption_price": ur.Quantity("0 EUR/MWh"), + "ems_production_breach_price_set": True, + }, + ), + # Case 5: only site-production-capacity given -> mirror image of case 4. + ( + {"commodity": "gas", "site-production-capacity": "5 MW"}, + { + "ems_consumption_capacity_in_mw": ur.Quantity("0 MW"), + "ems_production_capacity_in_mw": ur.Quantity("5 MW"), + "ems_power_capacity_in_mw": None, + "consumption_price": ur.Quantity("0 EUR/MWh"), + "production_price": ur.Quantity("0 EUR/MWh"), + "ems_consumption_breach_price_set": True, + }, + ), + # Case 6: only site-power-capacity given -> a *hard* constraint at that + # capacity (both site capacities set equal to it; no breach price filled + # in); 0 consumption- and production-price. + ( + {"commodity": "gas", "site-power-capacity": "5 MW"}, + { + "ems_consumption_capacity_in_mw": ur.Quantity("5 MW"), + "ems_production_capacity_in_mw": ur.Quantity("5 MW"), + "ems_power_capacity_in_mw": ur.Quantity("5 MW"), + "consumption_price": ur.Quantity("0 EUR/MWh"), + "production_price": ur.Quantity("0 EUR/MWh"), + "ems_consumption_breach_price_set": False, + "ems_production_breach_price_set": False, + }, + ), + # A multi-field combination: consumption-price given together with an + # explicit site-power-capacity. The site-power-capacity is not the *sole* + # field given, so it does not trigger the hard-constraint case; instead, + # each direction is filled in independently: consumption-price given -> + # site-consumption-capacity stays unlimited (implicitly bounded by + # site-power-capacity at the scheduler level); production side untouched + # -> 0 site-production-capacity (soft). + ( + { + "commodity": "gas", + "consumption-price": "10 EUR/MWh", + "site-power-capacity": "5 MW", + }, + { + "ems_consumption_capacity_in_mw": None, + "ems_production_capacity_in_mw": ur.Quantity("0 MW"), + "ems_power_capacity_in_mw": ur.Quantity("5 MW"), + "consumption_price": ur.Quantity("10 EUR/MWh"), + "ems_production_breach_price_set": True, + }, + ), + ], +) +def test_commodity_flex_context_smart_defaults(context_input, expected): + """Test the smarter defaults for commodity contexts (see + CommodityFlexContextSchema.fill_grid_connection_defaults). + + These are DB-free, direct schema loads (no sensors involved). + """ + from flexmeasures.data.schemas.scheduling import CommodityFlexContextSchema + + loaded = CommodityFlexContextSchema().load(context_input) + + for field in ( + "ems_consumption_capacity_in_mw", + "ems_production_capacity_in_mw", + "ems_power_capacity_in_mw", + "consumption_price", + "production_price", + ): + if field in expected: + _assert_quantity_or_none(loaded.get(field), expected[field]) + + if "ems_consumption_breach_price_set" in expected: + assert (loaded.get("ems_consumption_breach_price") is not None) == expected[ + "ems_consumption_breach_price_set" + ] + if "ems_production_breach_price_set" in expected: + assert (loaded.get("ems_production_breach_price") is not None) == expected[ + "ems_production_breach_price_set" + ] + + @pytest.mark.parametrize( ["flex_context_listing", "fails"], [ @@ -1137,6 +1282,71 @@ def test_flex_context_listing_shared_currency( check_schema_loads_data(schema=schema, data=flex_context_listing, fails=fails) +def test_flex_context_listing_tolerates_price_free_context_in_other_currency(): + """test_flex_context_listing_tolerates_price_free_context_in_other_currency: + a bare (price-free) commodity context must not trip the shared-currency check + against a differently-currencied portfolio, since it has no user-given prices + of its own -- its 0-price/breach-price fills should just inherit the + portfolio's real currency. + """ + schema = FlexContextSchema() + + # Case A: top-level price sets the portfolio currency. + loaded = schema.load( + { + "consumption-price": "10 USD/MWh", + "commodities": [ + {"commodity": "electricity", "consumption-price": "10 USD/MWh"}, + {"commodity": "gas"}, + ], + } + ) + assert loaded["shared_currency_unit"] == "USD" + gas_context = next( + c for c in loaded["commodity_contexts"] if c["commodity"] == "gas" + ) + assert gas_context["shared_currency_unit"] == "USD" + assert str(gas_context["consumption_price"].units) == "USD/MWh" + + # Case B: no top-level price; a sibling commodity context sets the currency. + loaded = schema.load( + { + "commodities": [ + {"commodity": "electricity", "consumption-price": "10 USD/MWh"}, + {"commodity": "gas"}, + ], + } + ) + assert loaded["shared_currency_unit"] == "USD" + gas_context = next( + c for c in loaded["commodity_contexts"] if c["commodity"] == "gas" + ) + assert gas_context["shared_currency_unit"] == "USD" + assert str(gas_context["consumption_price"].units) == "USD/MWh" + + # Case C: no price given anywhere -> falls back to EUR everywhere. + loaded = schema.load({"commodities": [{"commodity": "gas"}]}) + assert loaded["shared_currency_unit"] == "EUR" + gas_context = loaded["commodity_contexts"][0] + assert gas_context["shared_currency_unit"] == "EUR" + + # A genuine mismatch (both contexts have explicit, different currencies) must + # still be rejected. + check_schema_loads_data( + schema=schema, + data={ + "consumption-price": "10 USD/MWh", + "commodities": [ + {"commodity": "electricity", "consumption-price": "10 USD/MWh"}, + {"commodity": "gas", "consumption-price": "10 EUR/MWh"}, + ], + }, + fails={ + "commodities": "all prices in the flex-context must share the same currency unit" + }, + ) + + def test_flex_context_listing_rejects_duplicate_commodities(db, app): """test_flex_context_listing_rejects_duplicate_commodities: a commodity listed twice must be rejected.""" schema = FlexContextSchema() diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index be45760153..bb0f4bb134 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4575,6 +4575,10 @@ "name": { "type": "string" }, + "commodity": { + "type": "string", + "default": "electricity" + }, "baseline": {}, "up-price": {}, "down-price": {}