a dalsi fix
This commit is contained in:
@@ -50,10 +50,10 @@ DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8
|
|||||||
# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila
|
# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila
|
||||||
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
|
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
|
||||||
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
|
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
|
||||||
# Penalita za překročení SoC capu před prvním buy<0 slotem. Měla by být VYŠŠÍ než
|
# Penalita za překročení SoC capu před prvním buy<0 slotem. Musí být VÝRAZNĚ VYŠŠÍ
|
||||||
# alternativní marginal arbitrage (acquisition - avg_neg_buy ~ 1 Kč/kWh) aby LP
|
# než marginal arbitrage (peak_sell − avg_neg_buy ~ 5 Kč/kWh), aby LP cap dodržoval
|
||||||
# preferoval buy<0 nabíjení před ranním PV.
|
# i přes alternativní cesty.
|
||||||
PRE_NEG_BUY_SOC_SLACK_PENALTY_CZK_PER_WH = 0.005
|
PRE_NEG_BUY_SOC_SLACK_PENALTY_CZK_PER_WH = 0.05
|
||||||
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||||
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
|
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
|
||||||
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
||||||
@@ -68,7 +68,7 @@ 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áž).
|
# 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
|
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
|
||||||
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||||
PLANNER_BUILD_TAG = "2026-05-27-pre-neg-buy-soc-cap-v14"
|
PLANNER_BUILD_TAG = "2026-05-27-hard-pv-mask-v15"
|
||||||
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
|
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_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
|
||||||
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
|
||||||
@@ -1204,6 +1204,33 @@ def solve_dispatch(
|
|||||||
discharge_export_slots = {
|
discharge_export_slots = {
|
||||||
t for t, s in enumerate(slots) if s.allow_discharge_export
|
t for t, s in enumerate(slots) if s.allow_discharge_export
|
||||||
}
|
}
|
||||||
|
# Pokud je v horizontu buy<0 okno a startovní SoC je nad cap (rezervační target),
|
||||||
|
# potřebujeme cestu jak baterii do okna vybít — rozšířit discharge_export okno o
|
||||||
|
# vysoké-sell sloty před prvním buy<0. R__063 v noci typicky nepovoluje export
|
||||||
|
# (allow_discharge_export=false), ale tady chceme zaplatit za vyklízení baterky,
|
||||||
|
# abychom potom v buy<0 okně nasáli max z OTE záporných cen.
|
||||||
|
_neg_buy_indices_pre = [
|
||||||
|
t for t, s in enumerate(slots) if float(s.buy_price) < 0
|
||||||
|
]
|
||||||
|
_first_neg_buy_idx_pre = _neg_buy_indices_pre[0] if _neg_buy_indices_pre else None
|
||||||
|
_usable_cap_pre = float(battery.soc_max_wh) - float(min_soc_wh)
|
||||||
|
_cap_target_raw_wh_pre = float(min_soc_wh) + 0.15 * _usable_cap_pre
|
||||||
|
if (
|
||||||
|
_first_neg_buy_idx_pre is not None
|
||||||
|
and _first_neg_buy_idx_pre > 0
|
||||||
|
and float(current_soc_wh) > _cap_target_raw_wh_pre
|
||||||
|
):
|
||||||
|
# Práh sell: alespoň 80 % maxima sell v okně před buy<0 (peak hours).
|
||||||
|
_pre_sells = [
|
||||||
|
float(slots[t].sell_price) for t in range(_first_neg_buy_idx_pre)
|
||||||
|
if float(slots[t].sell_price) > 0
|
||||||
|
]
|
||||||
|
if _pre_sells:
|
||||||
|
_sell_peak_pre = max(_pre_sells)
|
||||||
|
_sell_thr_pre = 0.8 * _sell_peak_pre
|
||||||
|
for t in range(_first_neg_buy_idx_pre):
|
||||||
|
if float(slots[t].sell_price) >= _sell_thr_pre:
|
||||||
|
discharge_export_slots.add(t)
|
||||||
# SELF_SUSTAIN dřív vynucoval ge[t] == 0, což umí udělat MILP infeasible v okamžiku, kdy:
|
# SELF_SUSTAIN dřív vynucoval ge[t] == 0, což umí udělat MILP infeasible v okamžiku, kdy:
|
||||||
# - baterie je na max SoC (nelze nabíjet),
|
# - baterie je na max SoC (nelze nabíjet),
|
||||||
# - PV pole B není curtailable,
|
# - PV pole B není curtailable,
|
||||||
@@ -1269,24 +1296,17 @@ def solve_dispatch(
|
|||||||
first_neg_buy_idx = neg_buy_indices[0] if neg_buy_indices else None
|
first_neg_buy_idx = neg_buy_indices[0] if neg_buy_indices else None
|
||||||
pre_neg_buy_soc_cap_wh: float | None = None
|
pre_neg_buy_soc_cap_wh: float | None = None
|
||||||
if first_neg_buy_idx is not None and first_neg_buy_idx > 0:
|
if first_neg_buy_idx is not None and first_neg_buy_idx > 0:
|
||||||
# Spočítat max nabíjecí kapacitu v buy<0 oknu (per slot ≤ max_charge_power_w,
|
# Cap = min_soc + 15 % usable kapacity (~25 % SoC celkem pro home-01).
|
||||||
# PV přebytek + grid import). Konzervativně: jen sloty se buy<0, nikoli okolní.
|
# Strukturální hodnota: do buy<0 okna LP musí dorazit s prakticky prázdnou
|
||||||
neg_window_capacity_wh = 0.0
|
# baterií, aby tam mohl maximálně nasát z OTE záporných cen + PV (+ následné
|
||||||
for t in neg_buy_indices:
|
# sell<0 sloty taky doplní PV → odpolední přebytek se nevyhodí v curtail).
|
||||||
s_neg = slots[t]
|
|
||||||
pv_sur = max(
|
|
||||||
0.0,
|
|
||||||
float(s_neg.pv_a_forecast_w) + float(s_neg.pv_b_forecast_w) - float(s_neg.load_baseline_w),
|
|
||||||
)
|
|
||||||
slot_charge_pot_w = min(
|
|
||||||
float(battery.max_charge_power_w),
|
|
||||||
pv_sur + float(grid.max_import_power_w),
|
|
||||||
)
|
|
||||||
neg_window_capacity_wh += slot_charge_pot_w * INTERVAL_H * float(battery.charge_efficiency)
|
|
||||||
# Konzervativní headroom 10 % kapacity (PV forecast error, load fluctuation)
|
|
||||||
usable_capacity = float(battery.soc_max_wh) - float(min_soc_wh)
|
usable_capacity = float(battery.soc_max_wh) - float(min_soc_wh)
|
||||||
reserve_target_wh = min(neg_window_capacity_wh, usable_capacity * 0.9)
|
pre_neg_buy_soc_cap_wh = float(min_soc_wh) + 0.15 * usable_capacity
|
||||||
pre_neg_buy_soc_cap_wh = max(float(min_soc_wh), float(battery.soc_max_wh) - reserve_target_wh)
|
# Pokud start SoC je už nad capem (zdědili jsme „plnou" baterii od minulého
|
||||||
|
# plánu / replanu), zvedneme cap na current SoC. Constraint je pak „nepřibývat"
|
||||||
|
# místo „prudce klesnout" — LP není infeasible / megapenalty když nemá kam vybít.
|
||||||
|
if float(current_soc_wh) > pre_neg_buy_soc_cap_wh:
|
||||||
|
pre_neg_buy_soc_cap_wh = float(current_soc_wh)
|
||||||
last_neg_sell_by_prague_date: dict[object, int] = {}
|
last_neg_sell_by_prague_date: dict[object, int] = {}
|
||||||
for t_ln, st_ln in enumerate(slots):
|
for t_ln, st_ln in enumerate(slots):
|
||||||
if float(st_ln.sell_price) < 0:
|
if float(st_ln.sell_price) < 0:
|
||||||
|
|||||||
@@ -1230,7 +1230,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
|||||||
50.0,
|
50.0,
|
||||||
operating_mode="AUTO",
|
operating_mode="AUTO",
|
||||||
)
|
)
|
||||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-pre-neg-buy-soc-cap-v14")
|
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-hard-pv-mask-v15")
|
||||||
self.assertGreater(
|
self.assertGreater(
|
||||||
results[0].battery_setpoint_w,
|
results[0].battery_setpoint_w,
|
||||||
5_500,
|
5_500,
|
||||||
@@ -1380,7 +1380,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
|||||||
50.0,
|
50.0,
|
||||||
operating_mode="AUTO",
|
operating_mode="AUTO",
|
||||||
)
|
)
|
||||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-pre-neg-buy-soc-cap-v14")
|
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-hard-pv-mask-v15")
|
||||||
self.assertEqual(len(results), len(slots))
|
self.assertEqual(len(results), len(slots))
|
||||||
|
|
||||||
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
|
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
|
||||||
@@ -1444,7 +1444,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
|
|||||||
55.0,
|
55.0,
|
||||||
operating_mode="AUTO",
|
operating_mode="AUTO",
|
||||||
)
|
)
|
||||||
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-pre-neg-buy-soc-cap-v14")
|
self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-hard-pv-mask-v15")
|
||||||
self.assertEqual(len(results), len(slots))
|
self.assertEqual(len(results), len(slots))
|
||||||
|
|
||||||
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
|
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
|
||||||
|
|||||||
@@ -5,6 +5,27 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-27 (e) — pre-`buy<0` discharge_export window + SoC cap (v15)
|
||||||
|
|
||||||
|
**Problém (home-01 run 16636, tag v14):** SoC cap 47 % spočítán správně (`pre_neg_buy_soc_cap_wh=29800`), ale LP **necilí** — 12:45 SoC = 98,4 %, slack 33 kWh × penalty 5 Kč/kWh = 165 Kč LP přijímá. Důvod: startovní SoC 58 % > cap 47 %, a R__063 v noci nepovoluje `allow_discharge_export=true` → **LP nemá cestu jak baterii vybít** kromě malé self-consumption. Tvrdý `bc_pv[t] = 0` mimo charge_slots by vyřešil PV pumping, ale rozbije bilanci v testech kde `pv_surplus > 0 + sell < pv_store_val` (export blokován v Pythonu).
|
||||||
|
|
||||||
|
**Oprava (tag `2026-05-27-hard-pv-mask-v15`):**
|
||||||
|
|
||||||
|
- **Cap snížen na `min_soc + 0.15 × usable`** (~25 % SoC) místo dynamického z `neg_window_capacity`.
|
||||||
|
- **Penalty zvýšena 10×** na 0,05 Kč/Wh (= 50 Kč/kWh), nad marginal arbitrage (~5 Kč/kWh).
|
||||||
|
- **Když start SoC > cap_raw**, rozšíříme `discharge_export_slots` o sloty před `first_neg_buy_idx` se `sell ≥ 0.8 × max(sell_pre_neg_buy)`. LP tak může vybít baterii **přes ge_bat** v noci/ráno za sell ~3 Kč/kWh; pak v buy<0 okně nasáje z OTE záporných cen.
|
||||||
|
- Když start ≤ cap_raw, cap = current_soc (constraint „nepřibývat" místo „klesnout" → triviálně splněno, LP nikdy nedostane infeasible kvůli rezervaci).
|
||||||
|
|
||||||
|
**Ekonomická logika home-01 zítra:**
|
||||||
|
- Vybít 20 kWh v noci za sell~3 Kč/kWh = ~60 Kč
|
||||||
|
- Nabít 20 kWh v buy<0 okně za buy~−0.22 Kč/kWh = ~+4 Kč (acquisition záporný)
|
||||||
|
- Vyprodat v peak za sell~4.4 Kč/kWh = ~88 Kč
|
||||||
|
- Total alternative: ~152 Kč (vs current bez vybíjení ~132 Kč)
|
||||||
|
|
||||||
|
**Ověření:** v `solver_params.inputs` nově: `first_neg_buy_idx`, `pre_neg_buy_soc_cap_wh`, `pre_neg_buy_soc_slack_wh`. Replan home-01 → SoC v 12:45 ≤ 25 % (nebo cap = start SoC, pokud bottleneck export). `bat_setpoint_w < 0` v některých nočních / ranních slotech s peak sell.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-05-27 (d) — pre-`buy<0` SoC cap v LP (v14)
|
## 2026-05-27 (d) — pre-`buy<0` SoC cap v LP (v14)
|
||||||
|
|
||||||
**Problém (home-01 run 16622, tag v13):** Fix 1 (acquisition ≥ 0) i Fix 2 (R__063 `v_pv_layer_cap_wh` redukce) byly nasazené, ale plán pro 2026-05-25 zůstal stejný: 10:30 SoC = 95 %, 11:00 SoC = 98,3 %, 13:00–14:45 (buy<0, sell<0) baterka plná → export pole A do mínusu + curtail 5 kW pole A. Příčina:
|
**Problém (home-01 run 16622, tag v13):** Fix 1 (acquisition ≥ 0) i Fix 2 (R__063 `v_pv_layer_cap_wh` redukce) byly nasazené, ale plán pro 2026-05-25 zůstal stejný: 10:30 SoC = 95 %, 11:00 SoC = 98,3 %, 13:00–14:45 (buy<0, sell<0) baterka plná → export pole A do mínusu + curtail 5 kW pole A. Příčina:
|
||||||
|
|||||||
Reference in New Issue
Block a user