dalsi fix zapornoeho sellu u home-01
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-24 20:22:11 +02:00
parent 9a15a4c618
commit 2d021b15c3
4 changed files with 243 additions and 27 deletions

View File

@@ -59,7 +59,12 @@ NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0
NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8
# Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail).
NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35
PLANNER_BUILD_TAG = "2026-05-25-home01-neg-sell-evening-v10"
# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek.
NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0
# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž).
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
PLANNER_BUILD_TAG = "2026-05-26-neg-sell-bat-dump-extreme-buy-v11"
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
@@ -688,6 +693,70 @@ def _horizon_fixed_tariff_like(slots: list[PlanningSlot]) -> bool:
return max(buys) - min(buys) < 0.25
def _future_extreme_buy_from(
slots: list[PlanningSlot],
buy_thr: float,
) -> list[bool]:
"""True v t, pokud v některém budoucím slotu buy <= buy_thr."""
t_len = len(slots)
out = [False] * t_len
seen = False
for i in range(t_len - 1, -1, -1):
if float(slots[i].buy_price) <= buy_thr:
seen = True
out[i] = seen
return out
def _neg_sell_bat_dump_slots(
slots: list[PlanningSlot],
*,
operating_mode: str,
purchase_fixed: bool,
grid: Any,
buy_extreme_thr: float,
degrad_czk_kwh: float,
) -> set[int]:
"""Sloty, kde smí ge_bat>0 při sell<0 (výboj před extrémně záporným buy)."""
if operating_mode != "AUTO" or purchase_fixed:
return set()
if bool(getattr(grid, "block_export_on_negative_sell", False)):
return set()
t_len = len(slots)
future_extreme = _future_extreme_buy_from(slots, buy_extreme_thr)
dist = _slots_until_buy_le(slots, buy_extreme_thr)
out: set[int] = set()
for t, s in enumerate(slots):
if float(s.sell_price) >= 0.0:
continue
future_min = min(
(float(slots[j].buy_price) for j in range(t + 1, t_len)),
default=float(s.buy_price),
)
if (
future_extreme[t]
and 0 < dist[t] <= EXTREME_BUY_DUMP_PREWINDOW_SLOTS
and future_min < float(s.sell_price) - degrad_czk_kwh
):
out.add(t)
return out
def _slots_until_buy_le(
slots: list[PlanningSlot],
buy_thr: float,
) -> list[int]:
"""Počet slotů do nejbližšího buy <= thr (0 = v tomto slotu, T = nikdy)."""
t_len = len(slots)
dist = [t_len] * t_len
next_idx = t_len
for i in range(t_len - 1, -1, -1):
if float(slots[i].buy_price) <= buy_thr:
next_idx = i
dist[i] = (next_idx - i) if next_idx < t_len else t_len
return dist
def _pre_negative_sell_export_window(
slots: list[PlanningSlot],
) -> tuple[int | None, int | None]:
@@ -1138,6 +1207,8 @@ def solve_dispatch(
if float(slots[i].buy_price) < 0.0:
seen_neg_buy = True
future_neg_buy_from[i] = seen_neg_buy
future_extreme_buy_from = _future_extreme_buy_from(slots, buy_extreme_thr)
dist_to_extreme_buy = _slots_until_buy_le(slots, buy_extreme_thr)
# EV proměnné per vozidlo
ev_direct = [[pulp.LpVariable(f"evd_{e}_{t}", 0,
@@ -1195,6 +1266,14 @@ def solve_dispatch(
min_spread_pre = float(degradation_cost_effective)
purchase_fixed_pre = _purchase_pricing_fixed(grid)
fixed_tariff_like_pre = purchase_fixed_pre or _horizon_fixed_tariff_like(slots)
neg_sell_bat_dump_slots = _neg_sell_bat_dump_slots(
slots,
operating_mode=om,
purchase_fixed=purchase_fixed_pre,
grid=grid,
buy_extreme_thr=buy_extreme_thr,
degrad_czk_kwh=float(degradation_cost_effective),
)
profitable_export_ts_pre: set[int] = set()
if om == "AUTO":
for _t in range(T):
@@ -1281,6 +1360,7 @@ def solve_dispatch(
peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_sell_bat_dump_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
neg_sell_soc_underfill: list[tuple[int, pulp.LpVariable]] = []
fixed_tariff_like = fixed_tariff_like_pre
block_export_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
@@ -1352,6 +1432,14 @@ def solve_dispatch(
float(battery.usable_capacity_wh),
)
neg_sell_soc_underfill.append((t, us))
for t in neg_sell_bat_dump_slots:
dump_target_w = min(
float(EVENING_BATTERY_EXPORT_MIN_W),
float(battery.max_discharge_power_w),
float(grid.max_export_power_w),
)
sf_dump = pulp.LpVariable(f"neg_bat_dump_shortfall_{t}", 0, dump_target_w)
neg_sell_bat_dump_shortfall.append((t, sf_dump, dump_target_w))
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
# Kanály: gi×buy, ge_pv×sell, ge_bat×sell, +ge_bat×acquisition (export bat. jen v discharge slotách).
@@ -1395,6 +1483,18 @@ def solve_dispatch(
)
else 0
)
+ (
ge_pv[t]
* NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH
* INTERVAL_H
/ 1000
if (
om == "AUTO"
and float(slots[t].sell_price) < 0.0
and not purchase_fixed_pre
)
else 0
)
+ pulp.lpSum(
ev_direct[e][t] * slots[t].buy_price * INTERVAL_H / 1000
+ ev_via_bat[e][t] * slots[t].buy_price * EV_ROUNDTRIP_FACTOR * INTERVAL_H / 1000
@@ -1445,6 +1545,10 @@ def solve_dispatch(
us * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH
for _t, us in neg_sell_soc_underfill
)
+ pulp.lpSum(
sf * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in neg_sell_bat_dump_shortfall
)
+ pulp.lpSum(
-25.0 * z_export[t]
for t in range(T)
@@ -1457,6 +1561,8 @@ def solve_dispatch(
prob += sf >= cap_w - ge_bat[t_sf]
for t_sf, sf, cap_w in pv_charge_shortfall:
prob += sf >= cap_w - bc_pv[t_sf]
for t_sf, sf, cap_w in neg_sell_bat_dump_shortfall:
prob += sf >= cap_w - ge_bat[t_sf]
for t_us, us in neg_sell_soc_underfill:
prob += us >= float(battery.soc_max_wh) - soc[t_us]
preneg_export_min_soc_wh = float(min_soc_wh) + max(
@@ -1602,13 +1708,16 @@ def solve_dispatch(
+ heat_pump.rated_heating_power_w,
)
# Záporný prodej (sell < 0): baterii v tomhle okně nevybíjíme (dump má proběhnout předtím).
# Export v okně sell<0 může vzniknout jen z přebytku FVE (pv_a/pv_b). Výjimka: EV-via-battery
# (pokud by bylo připojené a požadovalo výkon) to kryjeme přes bd >= ev_via_bat.
# Záporný prodej (sell < 0): výboj baterie jen před extrémně záporným buy (v11).
# Export FVE při sell<0: spot = nabíjení/curtail A; ventil jen pole B při plné baterii.
if s.sell_price < 0:
prob += w_arb[t] == 0
prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV))
prob += ge_bat[t] == 0
block_neg_sell_export_t = bool(
getattr(grid, "block_export_on_negative_sell", False)
)
if t not in neg_sell_bat_dump_slots:
prob += ge_bat[t] == 0
ev_cap_neg = sum(
float(vehicles[e].max_charge_power_w)
for e in range(EV)
@@ -1640,32 +1749,25 @@ def solve_dispatch(
if block_pv_export_neg_sell:
prob += ge_pv[t] == 0
# Tvrdý zákaz vývozu jen při block_export_on_negative_sell (KV1).
# GEN cut-off (z_gen_cutoff) nesmí vynutit ge==0 — jinak nelze odvést pole B při plné baterii (BA81).
block_neg_sell_export = bool(
getattr(grid, "block_export_on_negative_sell", False)
)
if block_neg_sell_export:
if block_neg_sell_export_t:
prob += ge[t] == 0
prob += ge_pv[t] == 0
prob += ge_bat[t] == 0
elif purchase_fixed_pre:
# Fixní nákup + spot výkup (BA81, KV1 bez block_export): sell<0 = platíš za vývoz.
# Nesouvisí s NT/VT skokem buy — řídí se výkupní cenou, ne rozptylem buy v horizontu.
# Přebytek FVE → baterie / curtail A; B přes z_gen_cutoff nebo bc_pv.
prob += ge[t] == 0
prob += ge_pv[t] == 0
elif not purchase_fixed_pre and pv_surplus_neg_w > 500:
# Spot (home-01): při sell<0 neexportovat, dokud není baterie plná (curtailable A).
# Dříve skip_pv_store_block + pv_b vynucoval export i při prázdné baterii.
elif not purchase_fixed_pre:
# Spot (home-01): ge_pv=0 dokud není plná baterie; pak jen ventil pole B (ne celý surplus).
soc_prev_neg = current_soc_wh if t == 0 else soc[t - 1]
w_pv_full = pulp.LpVariable(f"w_pv_full_neg_{t}", cat=pulp.LpBinary)
w_pv_b_vent = pulp.LpVariable(f"w_pv_b_vent_neg_{t}", cat=pulp.LpBinary)
m_soc_neg = float(battery.soc_max_wh)
prob += soc_prev_neg >= (
float(battery.soc_max_wh)
m_soc_neg
- soc_headroom_wh
- float(battery.soc_max_wh) * (1 - w_pv_full)
- m_soc_neg * (1 - w_pv_b_vent)
)
prob += ge_pv[t] <= float(pv_surplus_neg_w) * w_pv_full
prob += ge[t] <= float(grid.max_export_power_w) * w_pv_full
prob += ge_pv[t] <= float(s.pv_b_forecast_w) * w_pv_b_vent
soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1]
arb_t = arb_floor_series[t]
@@ -1712,8 +1814,11 @@ def solve_dispatch(
# Významný export z baterie ⇒ koncové SoC ≥ podlaha (FVE export ge_pv bez této podlahy).
m_ge = float(grid.max_export_power_w)
m_soc_bigm = float(battery.usable_capacity_wh)
prob += ge_bat[t] <= m_ge * z_export[t]
prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t]
if t in neg_sell_bat_dump_slots:
prob += ge_bat[t] <= m_ge
else:
prob += ge_bat[t] <= m_ge * z_export[t]
prob += ge_bat[t] >= GE_MIN_EXPORT_W * z_export[t]
# Bez hluboké relaxace: export končí ≥ rezerva. Při hluboké relaxaci (soc_panel_min pod min_soc)
# sladit s LP spodkem — jinak z_export vynutil arb_base a blokoval vývoz k planner floor.
if (
@@ -1810,7 +1915,7 @@ def solve_dispatch(
prob += bc_pv[t] == 0
else:
prob += bc_pv[t] <= float(pv_surplus_w)
if t not in discharge_export_slots:
if t not in discharge_export_slots and t not in neg_sell_bat_dump_slots:
prob += ge_bat[t] == 0
prob += z_export[t] == 0