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,12 +1708,15 @@ 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))
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)
@@ -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,6 +1814,9 @@ 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)
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)
@@ -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

View File

@@ -1222,7 +1222,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-25-home01-neg-sell-evening-v10")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11")
self.assertGreater(
results[0].battery_setpoint_w,
5_500,
@@ -1372,7 +1372,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-25-home01-neg-sell-evening-v10")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11")
self.assertEqual(len(results), len(slots))
def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None:
@@ -1436,7 +1436,7 @@ class NegativeSellPvChargeTests(unittest.TestCase):
55.0,
operating_mode="AUTO",
)
self.assertEqual(snap.get("planner_build_tag"), "2026-05-25-home01-neg-sell-evening-v10")
self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11")
self.assertEqual(len(results), len(slots))
def test_fixed_tariff_neg_sell_no_grid_export(self) -> None:
@@ -2376,6 +2376,103 @@ class Home01RegressionTests(unittest.TestCase):
self.assertGreaterEqual(r.grid_setpoint_w, 0, "neg sell bez exportu při volné kapacitě baterie")
self.assertGreater(r.battery_setpoint_w, 0, "neg sell má nabíjet z FVE")
def test_neg_sell_full_battery_exports_at_most_pv_b_not_full_surplus(self) -> None:
"""Plná baterie + sell<0: max export jen pole B (~5 kW), ne pv_a+pv_b (~9 kW)."""
slots = [
PlanningSlot(
interval_start=datetime(2026, 5, 25, 7, 30, tzinfo=timezone.utc)
+ timedelta(minutes=15 * i),
buy_price=0.5,
sell_price=-0.4,
pv_a_forecast_w=4700,
pv_b_forecast_w=5100,
load_baseline_w=400,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
)
for i in range(3)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.2)
battery.max_charge_power_w = 18_000
battery.soc_max_wh = 64_000.0
grid = SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=13_500,
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = float(battery.soc_max_wh) - 500.0
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
soc0,
50.0,
operating_mode="AUTO",
)
for r in results:
export_w = max(0, -int(r.grid_setpoint_w or 0))
if export_w > 0:
self.assertLessEqual(
export_w,
5_500,
"při plné baterii jen ventil pole B, ne celý PV přebytek",
)
def test_neg_sell_bat_dump_slot_selection(self) -> None:
"""sell<0 těsně před buy<=-2: slot je v neg_sell_bat_dump_slots (ge_bat povolen)."""
from services.planning_engine import _neg_sell_bat_dump_slots
slots = [
PlanningSlot(
interval_start=datetime(2026, 4, 4, 5, 0, tzinfo=timezone.utc),
buy_price=0.3,
sell_price=-0.35,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=False,
),
PlanningSlot(
interval_start=datetime(2026, 4, 4, 5, 15, tzinfo=timezone.utc),
buy_price=-10.0,
sell_price=-0.2,
pv_a_forecast_w=0,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=False,
),
]
grid = SimpleNamespace(
block_export_on_negative_sell=False,
purchase_pricing_mode="spot",
)
dump = _neg_sell_bat_dump_slots(
slots,
operating_mode="AUTO",
purchase_fixed=False,
grid=grid,
buy_extreme_thr=-2.0,
degrad_czk_kwh=0.15,
)
self.assertEqual(dump, {0})
def test_no_fve_dump_at_low_sell_with_evening_peak(self) -> None:
"""Odpolední sell ~1,4 vs večer ~5,5 — žádný PV_SURPLUS export, nabíjení z FVE."""
base = datetime(2026, 5, 21, 14, 0, tzinfo=timezone.utc)

View File

@@ -19,7 +19,7 @@
- **Tvrdé výkonové limity site/baterie:** `gi ≤ site_grid_connection.max_import_power_w` (breaker); **`bc_pv + bc_gi ≤ asset_battery.max_charge_power_w`**; **`ge ≤ max_export_power_w`** (proměnná `ge`, platí `ge = ge_pv + ge_bat`); **`bd + ge_bat ≤ asset_battery.max_discharge_power_w`** (vybíjení do domu + export z baterie nesmí současně překročit BMS). Dříve LP dovoloval import+nabíjení a dvojnásobné nabíjení; u prodeje hrozilo současné `bd` a `ge_bat` až 2× max discharge — viz `SitePowerCapTests`.
- **Hodnota FVE (PV store value):** `ge_pv = 0`, pokud `sell < future_sell_opportunity degradation` (ne `charge_acquisition` — u fixního KV1 by jinak blokoval export při sell 2 Kč). **Před prvním `sell < 0` v horizontu:** při `sell ≥ 0` smí `ge_pv` až do `pv_sp` (strategie BA81: vyvézt přes poledne, pak nabít z FVE v záporném okně). Výjimka **nucený vent** jen plná baterie. Testy `Home01PvStoreValueTests`, `PreNegativeSellExportTests`.
- **Drahý nákup → vlastní spotřeba z baterie:** mimo `allow_charge` platí `bd + pv_ld ≥ load_baseline + hp[t]` a `gi ≤ EV + hp[t]` (ne `hp_rated`). **Spot:** drahý slot = `buy > min(buy≥0) + degradace`. **Fixní nákup (DB `purchase_pricing_mode=fixed` nebo heuristika rozptylu buy &lt; 0,25):** navíc `buy > charge_acquisition + degradace`. Na spotu **nesmí** `charge_acquisition` (~0,9 Kč) označit všechny sloty jako drahé → Infeasible (home-01). Při **Infeasible** solver jednou opakuje s `relaxed_expensive_import` (síť smí krmit baseload v drahých slotech; v `solver_params.inputs.relaxed_expensive_import=true`). Testy `AutoPassiveSelfConsumptionTests`, `test_spot_low_acquisition_does_not_mark_all_slots_expensive`, `test_negative_buy_in_horizon_does_not_block_all_grid_import`.
- **Záporný výkup (`sell < 0`) bez exportu:** `block_export_on_negative_sell` (KV1) **nebo** `purchase_pricing_mode=fixed` (BA81). **Spot (home-01):** export FVE jen při plné baterii (`w_pv_full_neg`); jinak nabíjení/curtail A — tag `2026-05-25-home01-neg-sell-evening-v10`. Večerní discharge maska u spotu: denní peak ≥17:00 (ne `sell > ref_buy` v slotu).
- **Záporný výkup (`sell < 0`) bez exportu:** `block_export_on_negative_sell` (KV1) **nebo** `purchase_pricing_mode=fixed` (BA81). **Spot (home-01):** `ge_pv=0` dokud není plná baterie; při plné jen ventil pole B (`ge_pv ≤ pv_b`, `w_pv_b_vent_neg`); výboj baterie při `sell<0` jen **12 slotů** před `buy ≤ planner_extreme_buy_threshold` (default 2), pokud spread do budoucna dává smysl — tag `2026-05-26-neg-sell-bat-dump-extreme-buy-v11`. Večerní discharge maska u spotu: denní peak ≥17:00 (ne `sell > ref_buy` v slotu).
- **Pole B při sell&lt;0 (home-01):** pokud `block_export_on_negative_sell = false`, LP nesmí vynutit `ge_pv = 0` (přebytek neriťitelného PV B). KV1 s `block_export = true` jen curtail A / nabíjení.
- **`ref_buy_min` (brána exportu):** `min(buy_price)` horizontu — jen „existuje levný nákup?“, **ne** průměrná cena nabití přes hodiny. Export sloty: `sell > ref_buy_min + degradation` (spot). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).
- Pokud `energy_to_fill <= 0` nebo `charge_slot_buffer = 0`: všechny sloty povoleny.

View File

@@ -5,6 +5,20 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
---
## 2026-05-26 (o) — home-01: neg. výkup bez placeného exportu FVE + dump baterie před extrémním buy
**Problém (run 16480, tag v10):** Po ranním nabití na `soc_max` solver při `sell<0` exportoval **celý PV přebytek** (~9 kW, `PV_SURPLUS`) — binárka `w_pv_full_neg` povolila `ge_pv ≤ pv_surplus` místo jen ventilu pole B. Zároveň `ge_bat=0` blokoval výboj baterie před oknem `buy ≤ 2` (round-trip arbitráž).
**Oprava (tag `2026-05-26-neg-sell-bat-dump-extreme-buy-v11`):**
- Spot `sell<0`: `ge_pv=0` dokud není plná baterie; při plné jen `ge_pv ≤ pv_b` (`w_pv_b_vent_neg`) + penalizace `NEG_SELL_PV_B_VENT_PENALTY` (4 Kč/kWh).
- Před extrémním buy (`buy ≤ planner_extreme_buy_threshold`, default 2): v okně **12 slotů** smí `ge_bat>0` při `sell<0`, pokud `min_buy_future < sell degrad`.
- Odstraněn `w_pv_full_neg` (export celého surplusu).
**Ověření:** `test_neg_sell_full_battery_exports_at_most_pv_b_not_full_surplus`, `test_neg_sell_bat_dump_before_extreme_buy`, `test_neg_sell_pv_to_battery_not_grid_when_soc_has_room`; po deploy replan home-01 — neg sell bez ~9 kW exportu.
---
## 2026-05-25 (n) — home-01 AUTO: záporný výkup bez exportu, večerní špička
**Problém (run 16412, AUTO):** Dnes večer téměř bez exportu (terminal SoC drží energii na zítřek); zítra 07:30+ masivní **PV_SURPLUS** při `sell<0` místo nabíjení; zítra večer export OK.