fix max sell z baterky
This commit is contained in:
@@ -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.
|
||||||
|
gi−ge 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é peak−degrad. 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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
3. **Ranní pásmo před prvním `sell < 0`:** hodiny **5–11** 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 **5–11** 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 (v24–v26) — 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 **5–11** 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é „peak−degrad“ 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,0–4,2 Kč → několik slotů na plný výkon; málo SoC → jen 1–2 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).
|
||||||
|
|||||||
@@ -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 (≥ max−0,05): **plný** `ge_bat`; **počet slotů** = Wh rozpočet, řazení `sell` desc | Není jediný slot; není široké peak−degrad 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**).
|
||||||
|
|||||||
Reference in New Issue
Block a user