fix BA a KV nefunkcni vecerni prodej
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-24 10:49:35 +02:00
parent ce571a93fa
commit bd06779fe5
4 changed files with 141 additions and 7 deletions

View File

@@ -57,7 +57,9 @@ PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0
# Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif).
NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8
PLANNER_BUILD_TAG = "2026-05-24-neg-sell-v3"
# 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-24-evening-export-v4"
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
@@ -649,11 +651,17 @@ def _slot_profitable_battery_export(
fixed_tariff: bool,
) -> bool:
"""
Export z baterie do sítě má kladnou marži vs. cena zásoby (acquisition).
U fixed tarifu nepoužívat buy v slotu (může být predikovaný OTE jiný den) — jen acquisition.
Export z baterie do sítě má kladnou marži.
Spot: sell > charge_acquisition + spread (energie ze sítě / vážený nákup).
Fixní tarif (BA81/KV1): stejně jako R__063 discharge maska — sell > buy + spread;
acquisition může být nafouknutá grid nabíjením a blokovat večerní špičku (3,7 < 3,9).
"""
sell_t = float(slot.sell_price)
acq = float(charge_acquisition_czk_kwh)
if fixed_tariff:
buy_t = float(slot.buy_price)
if buy_t >= 0.0:
return sell_t > buy_t + min_spread
return sell_t > acq + min_spread
@@ -1130,6 +1138,9 @@ def solve_dispatch(
fixed_tariff=fixed_tariff_like_pre,
):
profitable_export_ts_pre.add(_t)
elif slots[_t].allow_discharge_export:
# SQL maska (R__063) už vybrala slot — neblokovat push/shortfall kvůli acq.
profitable_export_ts_pre.add(_t)
if first_neg_sell_idx is not None and first_neg_sell_idx > 0 and floor_pct is not None:
# Kotva na ranním peaku (ne na posledním slotu před sell<0) — jinak dump až v 07:30.
if (
@@ -1204,6 +1215,7 @@ def solve_dispatch(
peak_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
pv_charge_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))
if om == "AUTO":
@@ -1255,6 +1267,25 @@ def solve_dispatch(
cap_w = float(min(pv_surplus_w, battery.max_charge_power_w))
sf_pv = pulp.LpVariable(f"post_neg_pv_shortfall_{t}", 0, cap_w)
pv_charge_shortfall.append((t, sf_pv, cap_w))
for t in range(T):
if float(slots[t].sell_price) >= 0:
continue
if t not in charge_slots:
continue
pv_surplus_w = max(
0.0,
float(slots[t].pv_a_forecast_w)
+ float(slots[t].pv_b_forecast_w)
- float(slots[t].load_baseline_w),
)
if pv_surplus_w <= 500:
continue
us = pulp.LpVariable(
f"neg_soc_under_{t}",
0,
float(battery.usable_capacity_wh),
)
neg_sell_soc_underfill.append((t, us))
# --- Úč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).
@@ -1344,6 +1375,15 @@ def solve_dispatch(
sf * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0
for _t, sf, _cap in pv_charge_shortfall
)
+ pulp.lpSum(
us * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH
for _t, us in neg_sell_soc_underfill
)
+ pulp.lpSum(
-25.0 * z_export[t]
for t in range(T)
if t in discharge_export_slots and t in profitable_export_ts_pre
)
)
# --- Omezení ---
@@ -1351,6 +1391,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_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(
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
@@ -1367,9 +1409,14 @@ def solve_dispatch(
for t_peak in morning_pre_neg_export_ts:
if t_peak in profitable_export_ts:
prob += ge_bat[t_peak] >= float(PRENEG_MORNING_EXPORT_MIN_W) * z_export[t_peak]
evening_export_push_w = min(
export_push_w,
float(battery.max_discharge_power_w) * 0.5,
)
for t_peak in evening_peak_export_ts:
if t_peak in profitable_export_ts:
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
if t_peak not in discharge_export_slots:
continue
prob += ge_bat[t_peak] >= evening_export_push_w * z_export[t_peak]
# Ostatní profitable sloty: jen shortfall penalizace (ne tvrdý push na celý horizont).
if t_anchor is not None and soc_anchor_slack is not None:
target_floor_wh = float(planner_floor_effective_wh)
@@ -1599,6 +1646,13 @@ def solve_dispatch(
export_soc_floor_t = float(soc_panel_min[t])
else:
export_soc_floor_t = float(arb_base_wh)
# Večerní exportní slot: podlaha jen min_soc (ne safety ramp), aby šlo vybít při z_export=1.
if (
om == "AUTO"
and t in discharge_export_slots
and t in evening_peak_export_ts
):
export_soc_floor_t = float(min_soc_wh)
# Safety export floor: v běžných (ne high-sell) slotech nevybít exportem energii potřebnou pro
# robustnost/noční baseload. Použije se pouze pokud je safety target v SQL vyplněný.
tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
@@ -1606,6 +1660,11 @@ def solve_dispatch(
tgt_s is not None
and not high_sell_slot[t]
and t not in profitable_export_ts_pre
and not (
om == "AUTO"
and t in discharge_export_slots
and t in evening_peak_export_ts
)
):
export_soc_floor_t = max(
export_soc_floor_t,