fix max sell z baterky
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-25 11:59:03 +02:00
parent c6074e9c74
commit 91af5c76c2
4 changed files with 246 additions and 28 deletions

View File

@@ -68,10 +68,11 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-05-28-pre-neg-buy-soc-phases-v25" PLANNER_BUILD_TAG = "2026-05-28-evening-peak-full-export-v26"
POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30 POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30
PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25 PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25
PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05
# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B). # buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B).
PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0 PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0
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
@@ -811,6 +812,47 @@ def _battery_export_cap_w(battery: Any, grid: Any) -> float:
) )
def _evening_push_battery_export_w(
slot: PlanningSlot,
battery: Any,
grid: Any,
) -> float:
"""
Nejvyšší ge_bat v push slotu při load-first: bd+ge_bat ≤ max_discharge, gi ≤ load+bc_gi.
Prakticky max export z baterie ≈ min(site/inverter cap, max_discharge load).
"""
cap = _battery_export_cap_w(battery, grid)
load_w = max(0.0, float(slot.load_baseline_w))
discharge_headroom = max(
0.0,
float(battery.max_discharge_power_w) - load_w,
)
return min(cap, discharge_headroom)
def _dispatch_grid_setpoint_w(
*,
gi_w: float,
ge_w: float,
ge_bat_w: float,
ge_pv_w: float,
max_export_power_w: int,
) -> tuple[int, str]:
"""
grid_setpoint pro export do sítě (záporný W) a export_mode.
gige může být ~0 při load-first, i když ge_bat exportuje — Deye reg 143 potřebuje |grid_setpoint|.
"""
ge_total = max(0.0, float(ge_w))
ge_bat_v = max(0.0, float(ge_bat_w))
cap = float(max_export_power_w)
if ge_bat_v >= GE_MIN_EXPORT_W:
export_w = min(cap, max(ge_total, ge_bat_v + max(0.0, float(ge_pv_w))))
return -int(round(export_w)), "BATTERY_SELL"
if ge_total >= GE_MIN_EXPORT_W:
return -int(round(min(cap, ge_total))), "PV_SURPLUS"
return round(float(gi_w) - ge_total), "NONE"
def _prague_hour(slot: PlanningSlot) -> int: def _prague_hour(slot: PlanningSlot) -> int:
dt = slot.interval_start dt = slot.interval_start
if dt.tzinfo is None: if dt.tzinfo is None:
@@ -972,8 +1014,11 @@ def _evening_battery_export_push_indices(
evening_start_hour: int = 17, evening_start_hour: int = 17,
) -> list[int]: ) -> list[int]:
""" """
Tvrdý push ge_bat u večerních peak slotů (profitable ∩ pásmo ≥17:00 degrad). Večerní push: plný ge_bat na top sell sloty (≥17h Prague).
Počet slotů = kolik jich unese rozpočet Wh (ne pevné top-3 / ≥2 sloty).
Ne jeden slot — kolik slotů unese Wh rozpočet (v24), seřazených sell desc.
Kandidáti jen u denního večerního max EVENING_PEAK_SELL_EPS (úzké pásmo),
ne celé široké peakdegrad. Ráno / odpoledne řeší jiné větve solveru.
""" """
if per_slot_discharge_wh <= 0.0: if per_slot_discharge_wh <= 0.0:
return [] return []
@@ -983,6 +1028,14 @@ def _evening_battery_export_push_indices(
evening_start_hour=evening_start_hour, evening_start_hour=evening_start_hour,
) )
candidates = [t for t in peak_ts if t in profitable_export_ts] candidates = [t for t in peak_ts if t in profitable_export_ts]
if not candidates:
return []
max_sell = max(float(slots[t].sell_price) for t in candidates)
candidates = [
t
for t in candidates
if float(slots[t].sell_price) >= max_sell - EVENING_PEAK_SELL_EPS_CZK_KWH
]
if not candidates: if not candidates:
return [] return []
push_budget_wh = _evening_push_discharge_budget_wh( push_budget_wh = _evening_push_discharge_budget_wh(
@@ -1491,6 +1544,48 @@ def solve_dispatch(
fixed_tariff=fixed_tariff_like_pre, fixed_tariff=fixed_tariff_like_pre,
): ):
profitable_export_ts_pre.add(_t) profitable_export_ts_pre.add(_t)
evening_push_ts: set[int] = set()
evening_early_export_penalty_ts: set[int] = set()
if om == "AUTO":
per_slot_discharge_wh_pre = max(
float(battery.max_discharge_power_w)
* float(battery.discharge_efficiency)
* INTERVAL_H,
0.0,
)
discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
evening_push_ts = set(
_evening_battery_export_push_indices(
slots,
profitable_export_ts=profitable_export_ts_pre,
degrad_czk_kwh=float(degradation_cost_effective),
current_soc_wh=float(current_soc_wh),
min_soc_wh=float(min_soc_wh),
soc_max_wh=float(battery.soc_max_wh),
per_slot_discharge_wh=per_slot_discharge_wh_pre,
discharge_slot_buffer=discharge_buf_pre,
)
)
max_evening_sell_by_day: dict[object, float] = {}
for t_ev, s_ev in enumerate(slots):
if _prague_hour(s_ev) < 17:
continue
d_ev = _prague_calendar_date(s_ev)
max_evening_sell_by_day[d_ev] = max(
max_evening_sell_by_day.get(d_ev, 0.0),
float(s_ev.sell_price),
)
for t_ev, s_ev in enumerate(slots):
if _prague_hour(s_ev) < 17:
continue
if t_ev not in profitable_export_ts_pre or t_ev not in discharge_export_slots:
continue
if t_ev in evening_push_ts:
continue
d_ev = _prague_calendar_date(s_ev)
peak_sell = max_evening_sell_by_day.get(d_ev, 0.0)
if float(s_ev.sell_price) < peak_sell - EVENING_PEAK_SELL_EPS_CZK_KWH:
evening_early_export_penalty_ts.add(t_ev)
last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy( last_pos_sell_pre_neg_buy = _last_non_negative_sell_before_neg_buy(
slots, first_neg_buy_idx slots, first_neg_buy_idx
) )
@@ -1604,6 +1699,8 @@ def solve_dispatch(
for t in range(T): for t in range(T):
if t not in discharge_export_slots: if t not in discharge_export_slots:
continue continue
if t in evening_push_ts:
continue
if not _slot_profitable_battery_export( if not _slot_profitable_battery_export(
slots[t], slots[t],
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
@@ -1849,6 +1946,10 @@ def solve_dispatch(
for t in range(T) for t in range(T)
if t in discharge_export_slots and t in profitable_export_ts_pre if t in discharge_export_slots and t in profitable_export_ts_pre
) )
+ pulp.lpSum(
-250.0 * z_export[t]
for t in evening_push_ts
)
) )
# --- Omezení --- # --- Omezení ---
@@ -1892,22 +1993,17 @@ def solve_dispatch(
for t_empty in pre_neg_buy_empty_ts: for t_empty in pre_neg_buy_empty_ts:
if t_empty in discharge_export_slots: if t_empty in discharge_export_slots:
prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty] prob += ge_bat[t_empty] >= export_push_w * z_export[t_empty]
discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0) for t_early in sorted(evening_early_export_penalty_ts):
evening_push_ts = _evening_battery_export_push_indices( prob += ge_bat[t_early] == 0
slots, for t_peak in sorted(evening_push_ts):
profitable_export_ts=profitable_export_ts,
degrad_czk_kwh=float(degradation_cost_effective),
current_soc_wh=float(current_soc_wh),
min_soc_wh=float(min_soc_wh),
soc_max_wh=float(battery.soc_max_wh),
per_slot_discharge_wh=per_slot_discharge_wh,
discharge_slot_buffer=discharge_buf,
)
for t_peak in evening_push_ts:
if t_peak not in discharge_export_slots: if t_peak not in discharge_export_slots:
continue continue
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak] push_floor_w = _evening_push_battery_export_w(
# Ostatní profitable sloty: shortfall penalizace (ne tvrdý push na celý horizont). slots[t_peak], battery, grid
)
if push_floor_w >= GE_MIN_EXPORT_W:
prob += ge_bat[t_peak] >= push_floor_w * z_export[t_peak]
# Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push).
if ( if (
last_pos_sell_pre_neg_buy is not None last_pos_sell_pre_neg_buy is not None
and pos_sell_soc_shortfall is not None and pos_sell_soc_shortfall is not None
@@ -2516,25 +2612,24 @@ def solve_dispatch(
hp_on = hp_raw > heat_pump.rated_heating_power_w * 0.3 hp_on = hp_raw > heat_pump.rated_heating_power_w * 0.3
bc_tot = float(pulp.value(bc_pv[t]) or 0) + float(pulp.value(bc_gi[t]) or 0) bc_tot = float(pulp.value(bc_pv[t]) or 0) + float(pulp.value(bc_gi[t]) or 0)
batt_w = round(bc_tot - float(pulp.value(bd[t]) or 0)) batt_w = round(bc_tot - float(pulp.value(bd[t]) or 0))
grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t])) ge_bat_w = round(float(pulp.value(ge_bat[t]) or 0))
ge_pv_w = round(float(pulp.value(ge_pv[t]) or 0))
grid_w, export_mode = _dispatch_grid_setpoint_w(
gi_w=float(pulp.value(gi[t]) or 0),
ge_w=float(pulp.value(ge[t]) or 0),
ge_bat_w=float(ge_bat_w),
ge_pv_w=float(ge_pv_w),
max_export_power_w=int(grid.max_export_power_w),
)
soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1) soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1)
export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0 export_limit_w = int(grid.max_export_power_w) if grid_w < 0 else 0
ge_bat_w = round(float(pulp.value(ge_bat[t]) or 0))
export_mode = "NONE"
if grid_w < 0:
export_mode = (
"BATTERY_SELL"
if ge_bat_w >= GE_MIN_EXPORT_W
else "PV_SURPLUS"
)
# Deye: default PASSIVE (střídač pokryje load). CHARGE/SELL jen v maskovaných AUTO slotech. # Deye: default PASSIVE (střídač pokryje load). CHARGE/SELL jen v maskovaných AUTO slotech.
deye_mode = "PASSIVE" deye_mode = "PASSIVE"
if om == "AUTO": if om == "AUTO":
if ( if (
slots[t].allow_discharge_export slots[t].allow_discharge_export
and batt_w < 0 and ge_bat_w >= GE_MIN_EXPORT_W
and grid_w < 0
): ):
deye_mode = "SELL" deye_mode = "SELL"
elif slots[t].allow_charge and batt_w > 0 and grid_w > 0: elif slots[t].allow_charge and batt_w > 0 and grid_w > 0:

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import unittest import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from zoneinfo import ZoneInfo
from services.planning_engine import ( from services.planning_engine import (
DispatchResult, DispatchResult,
@@ -2198,6 +2199,60 @@ class SpreadGuardHome01EconomicsTests(unittest.TestCase):
class ChargeAcquisitionArbitrageTests(unittest.TestCase): class ChargeAcquisitionArbitrageTests(unittest.TestCase):
"""Mezi-slotová arbitráž: večerní export při nízké charge_acquisition z SQL.""" """Mezi-slotová arbitráž: večerní export při nízké charge_acquisition z SQL."""
def test_evening_peak_battery_export_at_site_cap(self) -> None:
"""Nejvyšší večerní sell: výrazný export; levnější večerní sloty bez předčasného vývozu."""
prague = ZoneInfo("Europe/Prague")
base = datetime(2026, 5, 25, 17, 0, tzinfo=prague)
sells = [3.5, 3.7, 4.04, 3.75, 3.8, 3.6]
slots = [
PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=0.8,
sell_price=sell,
pv_a_forecast_w=6000,
pv_b_forecast_w=0,
load_baseline_w=800,
ev1_connected=False,
ev2_connected=False,
allow_charge=False,
allow_discharge_export=True,
charge_acquisition_buy_czk_kwh=0.8,
)
for i, sell in enumerate(sells)
]
battery = _battery(uc_wh=64_000.0, terminal_soc_value_factor=0.0)
battery.max_discharge_power_w = 6250
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
grid = SimpleNamespace(max_import_power_w=16_000, max_export_power_w=16_000)
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),
]
results, _ms, snap = solve_dispatch(
slots,
battery,
hp,
grid,
[None, None],
vehicles,
0.85 * battery.soc_max_wh,
50.0,
operating_mode="AUTO",
)
self.assertEqual(snap["planner_build_tag"], "2026-05-28-evening-peak-full-export-v26")
peak_idx = sells.index(4.04)
peak = results[peak_idx]
self.assertIn(peak.export_mode, ("BATTERY_SELL", "PV_SURPLUS"))
self.assertGreater(abs(peak.grid_setpoint_w), 5000)
for i, r in enumerate(results):
if i == peak_idx or sells[i] >= 4.04 - 0.05:
continue
self.assertNotEqual(
r.export_mode,
"BATTERY_SELL",
msg=f"slot {i} sell={sells[i]} must not battery-export before peak",
)
def test_evening_battery_export_when_sell_above_acquisition(self) -> None: def test_evening_battery_export_when_sell_above_acquisition(self) -> None:
base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc) base = datetime(2026, 5, 21, 10, 0, tzinfo=timezone.utc)
cheap = (0.75, 0.25) cheap = (0.75, 0.25)

View File

@@ -47,6 +47,7 @@
3. **Ranní pásmo před prvním `sell < 0`:** hodiny **511** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno. 3. **Ranní pásmo před prvním `sell < 0`:** hodiny **511** téhož kalendářního dne — všechny sloty s `sell ≥ lokální_max_ráno degradation`; ostatní sloty mezi ranním pásmem a prvním `sell < 0` s nižším sell mají export **zakázán** (žádný dump v 07:30 za 2 Kč). **`charge_acquisition`:** vážený `buy` před prvním exportem **téhož dne** jako záporné výkupní okno.
**Planner tag v25:** v24 + **dvoufáze před `buy<0`:** u posledního `sell≥0` cíl `soc≈max` (bez exportu); mezi tím a `buy<0` výboj na `_pre_neg_buy_soc_ceiling_wh`; v okně `buy<0` jen import (`bc_pv=0`), **curtail PV A jen při `buy<0`**; ranní `sell<0` před `buy<0` smí PV→bat. Viz changelog v25. **Planner tag v25:** v24 + **dvoufáze před `buy<0`:** u posledního `sell≥0` cíl `soc≈max` (bez exportu); mezi tím a `buy<0` výboj na `_pre_neg_buy_soc_ceiling_wh`; v okně `buy<0` jen import (`bc_pv=0`), **curtail PV A jen při `buy<0`**; ranní `sell<0` před `buy<0` smí PV→bat. Viz changelog v25.
**Planner tag v24:** v23 + **večerní tvrdý push** podle rozpočtu Wh (`discharge_slot_buffer`, SoC nad `min_soc`, `per_slot_discharge`) — bez pevného top-3 / `len≥2`. Viz changelog v24. **Planner tag v24:** v23 + **večerní tvrdý push** podle rozpočtu Wh (`discharge_slot_buffer`, SoC nad `min_soc`, `per_slot_discharge`) — bez pevného top-3 / `len≥2`. Viz changelog v24.
**Planner tag v26:** v25 + upřesnění večerního exportu — viz sekce **Večerní export z baterie** níže a changelog v26.
**Planner tag v23:** v22b + **výboj baterie do sítě** před `buy<0` (`_pre_neg_buy_discharge_indices`, sell≥1 Kč/kWh, push `ge_bat` z DB limitů). Viz changelog v23. **Planner tag v23:** v22b + **výboj baterie do sítě** před `buy<0` (`_pre_neg_buy_discharge_indices`, sell≥1 Kč/kWh, push `ge_bat` z DB limitů). Viz changelog v23.
V `solve_dispatch` (AUTO): **`charge_slots`** = `allow_charge` z DB + **`buy < 0`** + všechny sloty **`sell < 0`** s PV přebytkem > 500 W (i bez `block_export_on_negative_sell`, BA81). **`pv_charge_shortfall`** / **`NEG_SELL_CURTAIL_PENALTY`** platí v těchto slotech. Při **`sell < 0`**: safety deficit cílí **`soc_max_wh`** (plný planner strop). Po posledním **`sell < 0`** tentýž den: **`post_neg_pv_topup`** dobije z FVE na `soc_max` před exportem (kladný sell, ne high-sell peak). U **fixního tarifu** s polem B: **`ge_pv ≤ pv_b`** (ne pv_store **`ge_pv = 0`**). Při **`deye_gen_microinverter_cutoff_enabled`**: **`ge == 0` jen** pokud **`block_export_on_negative_sell`** (KV1), ne kvůli samotnému `z_gen_cutoff` (BA81 musí moci exportovat B při plné baterii). Vstupní **`soc_wh`** z telemetrie se před MILP omezí přes **`_planner_soc_for_solver`** (rezerva ~650 Wh pod `soc_max`, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu). **`planner_build_tag`** v `solver_params`. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md). V `solve_dispatch` (AUTO): **`charge_slots`** = `allow_charge` z DB + **`buy < 0`** + všechny sloty **`sell < 0`** s PV přebytkem > 500 W (i bez `block_export_on_negative_sell`, BA81). **`pv_charge_shortfall`** / **`NEG_SELL_CURTAIL_PENALTY`** platí v těchto slotech. Při **`sell < 0`**: safety deficit cílí **`soc_max_wh`** (plný planner strop). Po posledním **`sell < 0`** tentýž den: **`post_neg_pv_topup`** dobije z FVE na `soc_max` před exportem (kladný sell, ne high-sell peak). U **fixního tarifu** s polem B: **`ge_pv ≤ pv_b`** (ne pv_store **`ge_pv = 0`**). Při **`deye_gen_microinverter_cutoff_enabled`**: **`ge == 0` jen** pokud **`block_export_on_negative_sell`** (KV1), ne kvůli samotnému `z_gen_cutoff` (BA81 musí moci exportovat B při plné baterii). Vstupní **`soc_wh`** z telemetrie se před MILP omezí přes **`_planner_soc_for_solver`** (rezerva ~650 Wh pod `soc_max`, jinak Infeasible při 100 % SoC a dlouhém záporném výkupu). **`planner_build_tag`** v `solver_params`. Changelog: [`docs/planning-changelog.md`](../planning-changelog.md).
- **Záporná nákupní cena:** - **Záporná nákupní cena:**
@@ -63,6 +64,56 @@ Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z
- pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie) - pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie)
- kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu - kompromisy mezi prodejem, nabíjením, TČ a EV v globálním optimu
### Večerní export z baterie (v24v26) — co plánovač dělá a co ne
Cíl zůstává **maximální ekonomický užitek v celém horizontu**: prodat (a nabít) v časech, kdy to dává smysl podle cen a kapacity baterie. Večerní logika **neřeší ráno před FVE** a **nevnucuje jediný slot**.
#### Co se řeší jinde (není „večerní v26“)
| Čas / situace | Kde v kódu / SQL | Příklad |
|---------------|------------------|---------|
| Ráno **511** před prvním `sell < 0` | R__063 ranní pásmo + LP `morning_pre_neg_export_ts` | Export před záporným výkupním oknem, ne „před FVE“ jako takové |
| Odpoledne / noc, obecně profitable | `allow_discharge_export` z rozpočtu Wh + LP `peak_export_shortfall` | Kdekoliv v horizontu, pokud marže sedí |
| **≥ 17:00** večer | v24 Wh push + **v26** doplnění níže | Špička OTE večer |
#### Tři vrstvy večerního chování (od 17:00 Prague)
```mermaid
flowchart TD
A[LP: globální optimum v horizontu] --> B{slot >= 17h a profitable export?}
B -->|sell pod dennim vecer. max - 0.05| C[ge_bat = 0: baterie ne pred spickou]
B -->|sell v top pasme max - 0.05| D[evening_push kandidat]
D --> E[Seradit sell desc, pridat sloty az do Wh rozpoctu]
E --> F[ge_bat >= plny vykon na cap v kazdem push slotu]
C --> G[Vysledek: energie zustane na nejdrazsi vecer]
F --> G
```
1. **SQL masky (R__063, vrstva 2)** — které večerní sloty *smí* export z baterie vůbec (`allow_discharge_export`): mimo jiné sloty v pásmu „denní večerní max degrad“ (SQL), plus globální Wh rozpočet (vrstva 1).
2. **v26 — zákaz předčasného večerního vývozu** (`evening_early_export_penalty_ts` → tvrdé `ge_bat[t] = 0`):
- jen **hodiny ≥ 17** téhož kalendářního dne;
- jen pokud `sell` je **výrazně nižší** než denní večerní maximum: `sell < max_večer 0,05` Kč/kWh (`EVENING_PEAK_SELL_EPS_CZK_KWH`);
- **nezakazuje** přebytek FVE do sítě (`ge_pv`).
3. **v24 + v26 — plný výkon v top večerních slotech** (`evening_push_ts`):
- kandidáti: profitable ∩ večer ∩ `sell ≥ max_večer 0,05` (úzké pásmo u **absolutní** večerní špičky, ne široké „peakdegrad“ pro push);
- řazení podle **`sell` sestupně**;
- přidávat sloty, dokud `kumulované_Wh ≤` rozpočet (`discharge_slot_buffer`, SoC nad `min_soc`);
- **výsledek:** jeden nejdražší slot → jeden slot na plný výkon; několik slotů na 4,04,2 Kč → několik slotů na plný výkon; málo SoC → jen 12 nejlepší.
**Není to** „prodávat jen v jednom jediném nejdražším slotu“ — je to „prodávat **plným výkonem** v **tolika nejdražších večerních** slotech, kolik unese baterie“.
#### Co v26 opravilo oproti starému chování
| Dříve (problém) | Po v26 |
|-----------------|--------|
| Push kandidáti = široké pásmo `max degrad` (~15 haléřů) → vývoz i v 17:30 za 3,5 Kč | Push jen u `max 0,05` Kč/kWh |
| Měkká `peak_export_shortfall` → často ~50 % výkonu v mnoha slotech | Na `evening_push` slotech tvrdý push na cap; shortfall na push vypnutý |
| `grid_setpoint = gi ge` → Deye vidí ~0 W při velkém `ge_bat` | `_dispatch_grid_setpoint_w` z reálného exportu |
**Funkce:** `_evening_battery_export_push_indices`, `_evening_push_discharge_budget_wh`, `_evening_push_battery_export_w`, `_dispatch_grid_setpoint_w` v `planning_engine.py`. Tag: `2026-05-28-evening-peak-full-export-v26`.
### Arbitráž baterie — účtování mezi sloty (povinné čtení) ### Arbitráž baterie — účtování mezi sloty (povinné čtení)
**Detail:** [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). **Detail:** [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md).

View File

@@ -5,6 +5,23 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
--- ---
## 2026-05-28 — večerní export: plný výkon u top sell, bez předčasného vybití (v26)
**Problém:** Ve **stejném večeru** LP rozlévalo vývoz baterie do více slotů v širokém pásmu „denní večerní max degrad“ (řádově 0,15 Kč/kWh), často jen na **~50 %** výkonu (např. ~3,1 kW místo 6,25 kW u BA81). Před **nejdražší** čtvrthodinou už nezůstala energie na plný výkon; Deye pak jede na hard cap, ale plán to neodrážel (`grid_setpoint_w ≈ 1` při `BATTERY_SELL` u home-01).
**Změna (tag `2026-05-28-evening-peak-full-export-v26`)** — doplňuje v24 (Wh rozpočet), **nemění** globální ekonomiku LP. Detail: [`docs/04-modules/planning.md`](04-modules/planning.md) sekce *Večerní export z baterie*.
| Mechanismus | Co dělá | Co **nedělá** |
|-------------|---------|----------------|
| Globální LP | Max. zisk v horizontu; export kde sedí marže a masky | Není „jen jeden večerní slot“ |
| `evening_early` (`ge_bat = 0`) | Od **17:00**: `sell < denní_večerní_max 0,05` Kč/kWh — baterie nevybíjí *před* absolutní špičkou | **Neplatí ráno**; neblokuje `ge_pv` |
| `evening_push` | Top večerní sloty (≥ max0,05): **plný** `ge_bat`; **počet slotů** = Wh rozpočet, řazení `sell` desc | Není jediný slot; není široké peakdegrad pro push |
| `_dispatch_grid_setpoint_w` | `grid_setpoint_w` z `ge` / `ge_bat` pro Deye reg 143 | — |
**Ověření:** `pytest … -k evening_peak_battery_export_at_site_cap` · `planner_build_tag` = **v26**.
---
## 2026-05-28 — reg 340 cap z výkonu střídače, min dle firmware (V082) ## 2026-05-28 — reg 340 cap z výkonu střídače, min dle firmware (V082)
**Změna:** `asset_inverter.deye_reg340_max_solar_w` / `deye_reg340_min_solar_w`; `fn_inverter_pv_a_max_w` bere strop z DB sloupce (home-01 **32 000 W**, ostatní Deye **65 000 W**), ne součet Wp polí — studené panely mohou překročit nominál. `compute_pv_a_reg340_max_solar_w(..., min_w=)` — spodní limit jen pro kladné hodnoty (home-01 min **400 W**). **Změna:** `asset_inverter.deye_reg340_max_solar_w` / `deye_reg340_min_solar_w`; `fn_inverter_pv_a_max_w` bere strop z DB sloupce (home-01 **32 000 W**, ostatní Deye **65 000 W**), ne součet Wp polí — studené panely mohou překročit nominál. `compute_pv_a_reg340_max_solar_w(..., min_w=)` — spodní limit jen pro kladné hodnoty (home-01 min **400 W**).