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
1 change: 1 addition & 0 deletions documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Bugfixes
-----------
* Scheduling jobs no longer print ``Job ... made schedule.`` before ``scheduler.compute()`` runs (only after a successful schedule) [see `PR #2342 <https://www.github.com/FlexMeasures/flexmeasures/pull/2342>`_]
* ``flexmeasures add user --roles`` now correctly accepts a comma-separated list of roles (and repeated ``--roles`` options) instead of creating one role whose name contains commas [see `PR #2339 <https://www.github.com/FlexMeasures/flexmeasures/pull/2339>`_]
* Keep an explicit zero ``production-capacity`` or ``consumption-capacity`` as a hard device bound under ``relax-constraints``, so one-way devices cannot be scheduled in the forbidden direction [see `PR #2345 <https://www.github.com/FlexMeasures/flexmeasures/pull/2345>`_]
* Raise a clear ``ValueError`` when a flex-model references a missing sensor ID instead of ``AttributeError: 'NoneType' object has no attribute 'asset_id'`` [see `PR #2343 <https://www.github.com/FlexMeasures/flexmeasures/pull/2343>`_]
* Fix column sorting on the assets page, including when combined with the search filter [see `PR #2314 <https://www.github.com/FlexMeasures/flexmeasures/pull/2314>`_]
* Fix forecasting with past or future regressors, which raised a ``TypeError`` on pandas 2.2 and higher [see `PR #2303 <https://www.github.com/FlexMeasures/flexmeasures/pull/2303>`_]
Expand Down
14 changes: 12 additions & 2 deletions flexmeasures/data/models/planning/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,11 +1007,16 @@ def device_list_series(
min_value=0, # capacities are positive by definition
resolve_overlaps="min",
)
# Explicit zero production capacity is a physical impossibility (e.g.
# a heat pump), not an economic limit — keep it hard even when
# relax-constraints injects production-breach-price. Non-zero
# production capacities may still be softened. See issue #2323.
if (
self.flex_context.get("production_breach_price") is not None
and production_capacity[d] is not None
and production_capacity_d.max() > 0
):
# consumption-capacity will become a soft constraint
# production-capacity will become a soft constraint
production_breach_price = self.flex_context[
"production_breach_price"
]
Expand Down Expand Up @@ -1058,7 +1063,7 @@ def device_list_series(
)
commitments.append(commitment)
else:
# consumption-capacity will become a hard constraint
# production-capacity will become a hard constraint
device_constraints[d]["derivative min"] = -production_capacity_d
if sensor_d is not None and sensor_d.get_attribute(
"is_strictly_non_negative"
Expand All @@ -1078,9 +1083,14 @@ def device_list_series(
max_value=power_capacity_in_mw[d],
resolve_overlaps="min",
)
# Explicit zero consumption capacity is a physical impossibility
# (e.g. a pure generator), not an economic limit — keep it hard
# even when relax-constraints injects consumption-breach-price.
# Non-zero consumption capacities may still be softened. See #2323.
if (
self.flex_context.get("consumption_breach_price") is not None
and consumption_capacity[d] is not None
and consumption_capacity_d.max() > 0
):
# consumption-capacity will become a soft constraint
consumption_breach_price = self.flex_context[
Expand Down
270 changes: 270 additions & 0 deletions flexmeasures/data/models/planning/tests/test_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -1653,6 +1653,276 @@ def test_device_power_capacity_uses_directional_capacity_before_site_fallback(
assert np.allclose(device_constraints[0]["derivative max"], expected_derivative_max)


@pytest.mark.parametrize(
"direction, capacity_field, expected_derivative_col, expected_bound",
[
("production", "production-capacity", "derivative min", 0.0),
("consumption", "consumption-capacity", "derivative max", 0.0),
],
)
def test_explicit_zero_directional_capacity_stays_hard_under_relax_constraints(
db,
add_battery_assets,
direction,
capacity_field,
expected_derivative_col,
expected_bound,
):
"""Explicit zero directional capacity is physical, not economic (#2323).

With ``relax-constraints`` defaulting to True, non-zero capacity limits may be
softened via breach prices. An explicit ``production-capacity: 0`` (or
consumption-capacity: 0) must remain a hard bound — otherwise a consumption-only
device (e.g. heat pump) can be scheduled to produce when site breaches are
more expensive than device breaches.
"""
_, battery = get_sensors_from_db(db, add_battery_assets)

start = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 2))
end = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 3))
resolution = timedelta(minutes=15)

# Opposite direction stays open so the symmetric power-capacity fallback is not zero.
opposite_field = (
"consumption-capacity" if direction == "production" else "production-capacity"
)
scheduler = StorageScheduler(
asset_or_sensor=battery,
start=start,
end=end,
resolution=resolution,
flex_model={
"soc-at-start": "1 MWh",
"soc-min": "0 MWh",
"soc-max": "2 MWh",
"power-capacity": "2 MW",
capacity_field: "0 kW",
opposite_field: "2 MW",
},
# Default relax-constraints=True injects device breach prices.
flex_context={
"consumption-price": "1 EUR/MWh",
"production-price": "1000 EUR/MWh", # strong incentive to produce
# Cheap device breaches vs expensive site breaches — the incentive that
# previously made soft zero-capacity look rational to the LP.
"production-breach-price": "100 EUR/kW",
"consumption-breach-price": "100 EUR/kW",
"site-production-breach-price": "10000 EUR/kW",
"site-consumption-breach-price": "10000 EUR/kW",
"site-power-capacity": "1 kW",
},
)
scheduler.deserialize_config()

(
_sensors,
_start,
_end,
_resolution,
_soc_at_start,
device_constraints,
_ems_constraints,
commitments,
) = scheduler._prepare(skip_validation=True)

assert np.allclose(
device_constraints[0][expected_derivative_col], expected_bound
), (
f"explicit zero {capacity_field} must stay a hard {expected_derivative_col} "
f"bound under relax-constraints, got "
f"{device_constraints[0][expected_derivative_col].unique()}"
)

soft_breach_names = [
c.name
for c in commitments
if c.name is not None and f"{direction} breach device" in c.name
]
assert soft_breach_names == [], (
f"explicit zero {capacity_field} must not create soft "
f"{direction} breach commitments, got {soft_breach_names}"
)


@pytest.mark.parametrize(
"direction, capacity_field, expected_derivative_col",
[
("production", "production-capacity", "derivative min"),
("consumption", "consumption-capacity", "derivative max"),
],
)
def test_non_zero_directional_capacity_still_softens_under_breach_price(
db,
add_battery_assets,
direction,
capacity_field,
expected_derivative_col,
):
"""Non-zero directional capacity remains soft when breach prices are set (#2323).

Companion to ``test_explicit_zero_directional_capacity_stays_hard_under_relax_constraints``:
only the all-zero case stays hard; economic (non-zero) limits must still create soft
breach commitments and must not pin the hard derivative bound to that capacity.
"""
_, battery = get_sensors_from_db(db, add_battery_assets)

start = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 2))
end = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 3))
resolution = timedelta(minutes=15)

opposite_field = (
"consumption-capacity" if direction == "production" else "production-capacity"
)
scheduler = StorageScheduler(
asset_or_sensor=battery,
start=start,
end=end,
resolution=resolution,
flex_model={
"soc-at-start": "1 MWh",
"soc-min": "0 MWh",
"soc-max": "2 MWh",
"power-capacity": "2 MW",
capacity_field: "0.1 MW", # non-zero economic limit
opposite_field: "2 MW",
},
flex_context={
"consumption-price": "1 EUR/MWh",
"production-price": "1 EUR/MWh",
"production-breach-price": "100 EUR/kW",
"consumption-breach-price": "100 EUR/kW",
"site-power-capacity": "2 MW",
},
)
scheduler.deserialize_config()

(
_sensors,
_start,
_end,
_resolution,
_soc_at_start,
device_constraints,
_ems_constraints,
commitments,
) = scheduler._prepare(skip_validation=True)

soft_breach_names = [
c.name
for c in commitments
if c.name is not None and f"{direction} breach device" in c.name
]
assert soft_breach_names, (
f"non-zero {capacity_field} must still create soft "
f"{direction} breach commitments under breach prices"
)

# Hard bound should stay at power-capacity (±2 MW), not be pinned to the soft 0.1 MW.
if expected_derivative_col == "derivative min":
assert np.allclose(device_constraints[0][expected_derivative_col], -2.0), (
f"hard {expected_derivative_col} should remain power-capacity when "
f"non-zero {capacity_field} is softened"
)
else:
assert np.allclose(device_constraints[0][expected_derivative_col], 2.0), (
f"hard {expected_derivative_col} should remain power-capacity when "
f"non-zero {capacity_field} is softened"
)


@pytest.mark.parametrize(
[
"capacity_field",
"open_field",
"breach_price_field",
"prices",
"forbidden_direction",
],
[
(
"production-capacity",
"consumption-capacity",
"production-breach-price",
{
# Strong incentive to discharge (produce) if allowed.
"consumption-price": "1 EUR/MWh",
"production-price": "1000 EUR/MWh",
},
"produce",
),
(
"consumption-capacity",
"production-capacity",
"consumption-breach-price",
{
# Paid to consume — strong incentive to charge if allowed.
"consumption-price": "-1000 EUR/MWh",
"production-price": "1 EUR/MWh",
},
"consume",
),
],
)
def test_explicit_zero_directional_capacity_not_breached_in_schedule(
db,
add_battery_assets,
capacity_field,
open_field,
breach_price_field,
prices,
forbidden_direction,
):
"""Regression for #2323: never schedule in a direction with explicit capacity 0.

A soft interpretation of an all-zero directional capacity (via breach prices
that ``relax-constraints`` would inject) would allow power in a physically
impossible direction. Zero must stay hard for both production and consumption.

Same failure mode as EV chargers scheduled to discharge despite
``production-capacity: 0 W`` in #2329.
"""
_, battery = get_sensors_from_db(db, add_battery_assets)

start = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 1))
end = pytz.timezone("Europe/Amsterdam").localize(datetime(2015, 1, 2))
resolution = timedelta(minutes=15)

scheduler = StorageScheduler(
asset_or_sensor=battery,
start=start,
end=end,
resolution=resolution,
flex_model={
"soc-at-start": "1 MWh",
"soc-min": "0 MWh",
"soc-max": "2 MWh",
"power-capacity": "0.1 MW",
capacity_field: "0 kW",
open_field: "0.1 MW",
"prefer-charging-sooner": False,
},
flex_context={
**prices,
# Breach price that would soften non-zero limits; must not soften
# an explicit zero. Keep other constraints hard for a simple LP.
breach_price_field: "100 EUR/kW",
"site-power-capacity": "2 MW",
"relax-constraints": False,
},
)
schedule = scheduler.compute()
if forbidden_direction == "produce":
assert (schedule >= -TOLERANCE).all(), (
f"{capacity_field}: 0 must forbid negative (production) power even when "
f"{breach_price_field} is set; min scheduled power was {schedule.min()}"
)
else:
assert (schedule <= TOLERANCE).all(), (
f"{capacity_field}: 0 must forbid positive (consumption) power even when "
f"{breach_price_field} is set; max scheduled power was {schedule.max()}"
)


@pytest.mark.parametrize(
["soc_values", "log_message", "expected_num_targets"],
[
Expand Down
Loading