oprava nevyberu maximalnich sell slotu (sahal i na zitejsi vecer)
This commit is contained in:
37
.cursor/rules/ems-planning-agent-discipline.mdc
Normal file
37
.cursor/rules/ems-planning-agent-discipline.mdc
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: EMS plánování — doptat se, ekonomický zisk, bez mikrocyklů
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# EMS agent — plánování a ekonomika
|
||||||
|
|
||||||
|
## Doptat se
|
||||||
|
|
||||||
|
- Pokud zadání **není exaktní** (lokalita, časové okno, cílové SoC, co je bug vs. záměr), **vždy se doptat** před větší změnou kódu/SQL.
|
||||||
|
- Nehádat záměr uživatele (příklad: večerní export za ~3 Kč při buy ~5 Kč může být **správně** pro vyprázdnění před neg dnem).
|
||||||
|
|
||||||
|
## Ekonomický cíl
|
||||||
|
|
||||||
|
- Návrhy a implementace směřuj k **provoznímu zisku** (arbitráž, FVE, neg-sell okno, večerní špičky).
|
||||||
|
- **Výjimka:** neoptimalizovat **mikrocyklování** (souběžný import + export / zbytečné cykly v jednom slotu).
|
||||||
|
|
||||||
|
## Dvě podlahy SoC (home-01, sloupce v `ems.asset_battery`)
|
||||||
|
|
||||||
|
| Sloupec | % | Role |
|
||||||
|
|--------|---|------|
|
||||||
|
| **`reserve_soc_percent`** | 20 | **Export / strategie:** večerní push, ranní peak před `sell<0`, kotvy `neg_evening_reserve_soc_anchors` — cíl „ráno ~20 % před FVE“. Pod tímto plánovač **neplánuje zbytečný export** (V027 komentář). |
|
||||||
|
| **`min_soc_percent`** | 10 | **Spotřeba domu (Deye PASSIVE):** LP a exekuce smí vybíjet baterii pro load až sem — rezerva na **nenadálou spotřebu**, aby se nekupovalo ze sítě za draho. |
|
||||||
|
| **`planner_discharge_floor_percent`** | 5 | Jen **LP relaxace** pod `min_soc` (ne provozní cíl). |
|
||||||
|
|
||||||
|
**Nesplést:** vybít kvůli **prodeji** → podlaha **reserve**; vybít kvůli **domu v noci** → může jít k **min_soc**.
|
||||||
|
|
||||||
|
## Neg okno vs. `buy < 0`
|
||||||
|
|
||||||
|
- **`sell < 0`:** export zakázán; **headroom** = místo v baterii pro FVE v okně (v44 `neg_day_no_grid_before_neg_sell`, prep rampa). **Ne** totéž co „vyčerpat před sell<0“ u **`buy < 0`**.
|
||||||
|
- **`buy < 0`:** levné **nabíjení ze sítě** (priorita importu), ne strategie „vyprázdnit před neg výkupen“.
|
||||||
|
|
||||||
|
Před implementací změny exportních podlah: **zeptat se**, jestli cíl je „k 20 % před svítáním“ vs. „ještě níž pro headroom v sell<0“.
|
||||||
|
|
||||||
|
## Komunikace
|
||||||
|
|
||||||
|
- Bez ritualního „máš pravdu“; konkrétní fakta z DB/MCP, co změnit, jak ověřit.
|
||||||
@@ -71,7 +71,9 @@ 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-30-post-push-night-battery-v47"
|
PLANNER_BUILD_TAG = "2026-05-31-evening-push-budget-primary-night-v49"
|
||||||
|
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
|
||||||
|
DAWN_LOW_PV_NO_CURTAIL_W = 1500
|
||||||
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
|
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
|
||||||
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
|
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
|
||||||
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
|
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
|
||||||
@@ -1636,19 +1638,27 @@ def _evening_peak_export_indices(
|
|||||||
return sorted(out)
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _planner_discharge_floor_wh(battery: Any) -> float:
|
||||||
|
"""Provozní podlaha vývoje: reserve_soc (domluva), ne jen min_soc."""
|
||||||
|
return max(
|
||||||
|
float(getattr(battery, "min_soc_wh", 0.0)),
|
||||||
|
float(getattr(battery, "reserve_soc_wh", 0.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _evening_push_discharge_budget_wh(
|
def _evening_push_discharge_budget_wh(
|
||||||
*,
|
*,
|
||||||
current_soc_wh: float,
|
current_soc_wh: float,
|
||||||
min_soc_wh: float,
|
discharge_floor_wh: float,
|
||||||
soc_max_wh: float,
|
soc_max_wh: float,
|
||||||
discharge_slot_buffer: float,
|
discharge_slot_buffer: float,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
Rozpočet Wh pro tvrdý večerní push — stejný princip jako R__063 (discharge_slot_buffer).
|
Rozpočet Wh pro tvrdý večerní push — stejný princip jako R__063 (discharge_slot_buffer).
|
||||||
Tvrdý push nesmí překročit energii nad min_soc na začátku horizontu (jinak Infeasible).
|
Podlaha = reserve_soc (typ. 20 %), ne min_soc (10 %).
|
||||||
"""
|
"""
|
||||||
exportable_full_wh = max(0.0, float(soc_max_wh) - float(min_soc_wh))
|
exportable_full_wh = max(0.0, float(soc_max_wh) - float(discharge_floor_wh))
|
||||||
available_wh = max(0.0, float(current_soc_wh) - float(min_soc_wh))
|
available_wh = max(0.0, float(current_soc_wh) - float(discharge_floor_wh))
|
||||||
buf = float(discharge_slot_buffer)
|
buf = float(discharge_slot_buffer)
|
||||||
if buf <= 0.0:
|
if buf <= 0.0:
|
||||||
return available_wh
|
return available_wh
|
||||||
@@ -1745,6 +1755,36 @@ def _evening_push_calendar_segments(
|
|||||||
return [sorted(v) for v in by_date.values() if v]
|
return [sorted(v) for v in by_date.values() if v]
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_night_export_segment_indices(slots: list[PlanningSlot]) -> set[int]:
|
||||||
|
"""
|
||||||
|
První noční epizoda v horizontu (17h → půlnoc → do východu FVE), která platí pro
|
||||||
|
rozpočet Wh z aktuální SoC. Další večery v horizontu (po dni FVE / nabíjení) se
|
||||||
|
plánují až vlastním rolling replanem — nesdílí dnešní baterii.
|
||||||
|
"""
|
||||||
|
segs = _night_export_window_segments(slots)
|
||||||
|
if not segs:
|
||||||
|
return set()
|
||||||
|
for seg in segs:
|
||||||
|
if 0 in seg:
|
||||||
|
return set(seg)
|
||||||
|
return set(segs[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _evening_push_soc_budget_calendar_segments(
|
||||||
|
slots: list[PlanningSlot],
|
||||||
|
discharge_export_ok: set[int] | None = None,
|
||||||
|
) -> list[list[int]]:
|
||||||
|
"""Kalendářní večery jen v primární noční epizodě — vhodné pro push_budget z current_soc."""
|
||||||
|
primary = _primary_night_export_segment_indices(slots)
|
||||||
|
if not primary:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
seg
|
||||||
|
for seg in _evening_push_calendar_segments(slots, discharge_export_ok)
|
||||||
|
if seg and all(t in primary for t in seg)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _night_self_consume_discourage_import_indices(
|
def _night_self_consume_discourage_import_indices(
|
||||||
slots: list[PlanningSlot],
|
slots: list[PlanningSlot],
|
||||||
*,
|
*,
|
||||||
@@ -1786,7 +1826,9 @@ def _evening_battery_export_push_indices(
|
|||||||
) -> list[int]:
|
) -> list[int]:
|
||||||
"""
|
"""
|
||||||
Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh
|
Večerní push (≥17h): plný ge_bat v nejdražších slotách (sell desc), rozpočet Wh
|
||||||
**per kalendářní večer** — druhý den v horizontu nedostane nulový push (v42 bug).
|
z aktuální SoC jen pro **primární noční epizodu** (dnešní večer → ráno).
|
||||||
|
Zítřejší večer v horizontu se nekrade polovinou budgetu (v43 split) — nabije se
|
||||||
|
přes den / neg okno; push přidá zítřejší rolling replan.
|
||||||
per_slot_discharge_wh: min(BMS, export cap) × účinnost × 0,25 h.
|
per_slot_discharge_wh: min(BMS, export cap) × účinnost × 0,25 h.
|
||||||
"""
|
"""
|
||||||
_ = evening_start_hour # kompatibilita volání
|
_ = evening_start_hour # kompatibilita volání
|
||||||
@@ -1794,36 +1836,40 @@ def _evening_battery_export_push_indices(
|
|||||||
return []
|
return []
|
||||||
push_budget_wh = _evening_push_discharge_budget_wh(
|
push_budget_wh = _evening_push_discharge_budget_wh(
|
||||||
current_soc_wh=current_soc_wh,
|
current_soc_wh=current_soc_wh,
|
||||||
min_soc_wh=min_soc_wh,
|
discharge_floor_wh=min_soc_wh,
|
||||||
soc_max_wh=soc_max_wh,
|
soc_max_wh=soc_max_wh,
|
||||||
discharge_slot_buffer=discharge_slot_buffer,
|
discharge_slot_buffer=discharge_slot_buffer,
|
||||||
)
|
)
|
||||||
if push_budget_wh < per_slot_discharge_wh * 0.5:
|
if push_budget_wh < per_slot_discharge_wh * 0.5:
|
||||||
return []
|
return []
|
||||||
evening_segments = _evening_push_calendar_segments(
|
evening_segments = _evening_push_soc_budget_calendar_segments(
|
||||||
slots,
|
slots,
|
||||||
discharge_export_ok=discharge_export_ok,
|
discharge_export_ok=discharge_export_ok,
|
||||||
)
|
)
|
||||||
if not evening_segments:
|
if not evening_segments:
|
||||||
return []
|
return []
|
||||||
seg_budget_wh = push_budget_wh / float(len(evening_segments))
|
candidates: list[int] = []
|
||||||
out: list[int] = []
|
seen: set[int] = set()
|
||||||
for seg in evening_segments:
|
for seg in evening_segments:
|
||||||
candidates = _evening_push_segment_candidates(
|
for t in _evening_push_segment_candidates(
|
||||||
slots,
|
slots,
|
||||||
seg,
|
seg,
|
||||||
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||||
min_spread=degrad_czk_kwh,
|
min_spread=degrad_czk_kwh,
|
||||||
discharge_export_ok=discharge_export_ok,
|
discharge_export_ok=discharge_export_ok,
|
||||||
)
|
):
|
||||||
|
if t not in seen:
|
||||||
|
seen.add(t)
|
||||||
|
candidates.append(t)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
continue
|
return []
|
||||||
ranked = sorted(
|
ranked = sorted(
|
||||||
candidates,
|
candidates,
|
||||||
key=lambda i: (float(slots[i].sell_price), -i),
|
key=lambda i: (float(slots[i].sell_price), -i),
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
remaining_wh = float(seg_budget_wh)
|
remaining_wh = float(push_budget_wh)
|
||||||
|
out: list[int] = []
|
||||||
for t in ranked:
|
for t in ranked:
|
||||||
if remaining_wh + 1e-6 < per_slot_discharge_wh:
|
if remaining_wh + 1e-6 < per_slot_discharge_wh:
|
||||||
break
|
break
|
||||||
@@ -2532,13 +2578,14 @@ def solve_dispatch(
|
|||||||
export_cap_push_w * float(battery.discharge_efficiency) * INTERVAL_H,
|
export_cap_push_w * float(battery.discharge_efficiency) * INTERVAL_H,
|
||||||
)
|
)
|
||||||
discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
discharge_buf_pre = float(getattr(battery, "discharge_slot_buffer", 0) or 0)
|
||||||
|
discharge_floor_wh = _planner_discharge_floor_wh(battery)
|
||||||
computed_evening_push_ts = set(
|
computed_evening_push_ts = set(
|
||||||
_evening_battery_export_push_indices(
|
_evening_battery_export_push_indices(
|
||||||
slots,
|
slots,
|
||||||
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
charge_acquisition_czk_kwh=charge_acquisition_czk_kwh,
|
||||||
degrad_czk_kwh=float(degradation_cost_effective),
|
degrad_czk_kwh=float(degradation_cost_effective),
|
||||||
current_soc_wh=float(current_soc_wh),
|
current_soc_wh=float(current_soc_wh),
|
||||||
min_soc_wh=float(min_soc_wh),
|
min_soc_wh=float(discharge_floor_wh),
|
||||||
soc_max_wh=float(battery.soc_max_wh),
|
soc_max_wh=float(battery.soc_max_wh),
|
||||||
per_slot_discharge_wh=per_slot_push_wh_pre,
|
per_slot_discharge_wh=per_slot_push_wh_pre,
|
||||||
discharge_slot_buffer=discharge_buf_pre,
|
discharge_slot_buffer=discharge_buf_pre,
|
||||||
@@ -3181,11 +3228,13 @@ def solve_dispatch(
|
|||||||
if om == "AUTO":
|
if om == "AUTO":
|
||||||
profitable_export_ts = profitable_export_ts_pre
|
profitable_export_ts = profitable_export_ts_pre
|
||||||
export_push_w = _battery_export_cap_w(battery, grid)
|
export_push_w = _battery_export_cap_w(battery, grid)
|
||||||
|
discharge_floor_wh = _planner_discharge_floor_wh(battery)
|
||||||
for t_peak in morning_pre_neg_export_ts:
|
for t_peak in morning_pre_neg_export_ts:
|
||||||
if t_peak in profitable_export_ts:
|
if t_peak in profitable_export_ts:
|
||||||
if _battery_export_push_defer_to_pv(slots[t_peak]):
|
if _battery_export_push_defer_to_pv(slots[t_peak]):
|
||||||
continue
|
continue
|
||||||
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
|
prob += ge_bat[t_peak] >= export_push_w * z_export[t_peak]
|
||||||
|
prob += soc[t_peak] >= float(discharge_floor_wh)
|
||||||
for t_pnd in pre_neg_buy_discharge_ts:
|
for t_pnd in pre_neg_buy_discharge_ts:
|
||||||
if _battery_export_push_defer_to_pv(slots[t_pnd]):
|
if _battery_export_push_defer_to_pv(slots[t_pnd]):
|
||||||
continue
|
continue
|
||||||
@@ -3206,6 +3255,7 @@ def solve_dispatch(
|
|||||||
if push_floor_w >= GE_MIN_EXPORT_W:
|
if push_floor_w >= GE_MIN_EXPORT_W:
|
||||||
prob += z_export[t_peak] == 1
|
prob += z_export[t_peak] == 1
|
||||||
prob += ge_bat[t_peak] >= push_floor_w
|
prob += ge_bat[t_peak] >= push_floor_w
|
||||||
|
prob += soc[t_peak] >= float(discharge_floor_wh)
|
||||||
# Ostatní profitable sloty: měkká shortfall penalizace (ne večerní push).
|
# 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
|
||||||
@@ -3730,6 +3780,10 @@ def solve_dispatch(
|
|||||||
and not skip_pv_store_block
|
and not skip_pv_store_block
|
||||||
and not fixed_pv_b_export_cap
|
and not fixed_pv_b_export_cap
|
||||||
and sell_t < pv_store_val
|
and sell_t < pv_store_val
|
||||||
|
and not (
|
||||||
|
sell_t >= 0.0
|
||||||
|
and int(s.pv_a_forecast_w) < DAWN_LOW_PV_NO_CURTAIL_W
|
||||||
|
)
|
||||||
and not _pv_forced_vent_export_allowed(
|
and not _pv_forced_vent_export_allowed(
|
||||||
t,
|
t,
|
||||||
current_soc_wh=current_soc_wh,
|
current_soc_wh=current_soc_wh,
|
||||||
@@ -4865,6 +4919,15 @@ async def _rolling_evening_push_override(
|
|||||||
if not isinstance(prev_iso, list) or not prev_iso:
|
if not isinstance(prev_iso, list) or not prev_iso:
|
||||||
return None
|
return None
|
||||||
prev_push = _evening_push_ts_from_iso(slots, [str(x) for x in prev_iso])
|
prev_push = _evening_push_ts_from_iso(slots, [str(x) for x in prev_iso])
|
||||||
|
if not prev_push:
|
||||||
|
return None
|
||||||
|
budget_eligible = {
|
||||||
|
t
|
||||||
|
for seg in _evening_push_soc_budget_calendar_segments(slots, None)
|
||||||
|
for t in seg
|
||||||
|
}
|
||||||
|
if budget_eligible:
|
||||||
|
prev_push = {t for t in prev_push if t in budget_eligible}
|
||||||
if not prev_push:
|
if not prev_push:
|
||||||
return None
|
return None
|
||||||
prev_peak = inputs.get("evening_push_peak_sell_czk_kwh")
|
prev_peak = inputs.get("evening_push_peak_sell_czk_kwh")
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ from services.planning_engine import (
|
|||||||
_evening_peak_export_indices,
|
_evening_peak_export_indices,
|
||||||
_slot_evening_push_profitable,
|
_slot_evening_push_profitable,
|
||||||
_evening_push_calendar_segments,
|
_evening_push_calendar_segments,
|
||||||
|
_evening_push_soc_budget_calendar_segments,
|
||||||
_evening_push_discharge_budget_wh,
|
_evening_push_discharge_budget_wh,
|
||||||
|
_primary_night_export_segment_indices,
|
||||||
_in_evening_push_hour_window,
|
_in_evening_push_hour_window,
|
||||||
_in_night_battery_export_window,
|
_in_night_battery_export_window,
|
||||||
_neg_sell_day_phases,
|
_neg_sell_day_phases,
|
||||||
@@ -285,15 +287,14 @@ class EveningPushBudgetTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(push, [])
|
self.assertEqual(push, [])
|
||||||
|
|
||||||
def test_per_calendar_evening_push_budget_split(self) -> None:
|
def test_evening_push_budget_only_primary_night_episode(self) -> None:
|
||||||
"""Dva večery v horizontu → každý dostane část Wh rozpočtu (druhý den ne prázdný)."""
|
"""v49: Wh z current_soc jen pro první noční epizodu — ne zítřejší večer po dni FVE."""
|
||||||
prague = ZoneInfo("Europe/Prague")
|
prague = ZoneInfo("Europe/Prague")
|
||||||
slots: list[PlanningSlot] = []
|
slots: list[PlanningSlot] = []
|
||||||
for day in (25, 26):
|
|
||||||
for h, m in ((18, 0), (18, 15), (18, 30)):
|
for h, m in ((18, 0), (18, 15), (18, 30)):
|
||||||
slots.append(
|
slots.append(
|
||||||
PlanningSlot(
|
PlanningSlot(
|
||||||
interval_start=datetime(2026, 5, day, h, m, tzinfo=prague),
|
interval_start=datetime(2026, 5, 25, h, m, tzinfo=prague),
|
||||||
buy_price=5.0,
|
buy_price=5.0,
|
||||||
sell_price=4.0 + 0.1 * (h - 18),
|
sell_price=4.0 + 0.1 * (h - 18),
|
||||||
pv_a_forecast_w=0,
|
pv_a_forecast_w=0,
|
||||||
@@ -305,8 +306,45 @@ class EveningPushBudgetTests(unittest.TestCase):
|
|||||||
charge_acquisition_buy_czk_kwh=0.5,
|
charge_acquisition_buy_czk_kwh=0.5,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
segs = _evening_push_calendar_segments(slots, discharge_export_ok=set(range(len(slots))))
|
# Denní FVE mezi večery → druhá noční epizoda (zítřejší večer nesmí brát SoC rozpočet).
|
||||||
self.assertEqual(len(segs), 2)
|
slots.append(
|
||||||
|
PlanningSlot(
|
||||||
|
interval_start=datetime(2026, 5, 26, 11, 0, tzinfo=prague),
|
||||||
|
buy_price=3.0,
|
||||||
|
sell_price=2.0,
|
||||||
|
pv_a_forecast_w=3000,
|
||||||
|
pv_b_forecast_w=0,
|
||||||
|
load_baseline_w=800,
|
||||||
|
ev1_connected=False,
|
||||||
|
ev2_connected=False,
|
||||||
|
allow_discharge_export=True,
|
||||||
|
charge_acquisition_buy_czk_kwh=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for h, m in ((18, 0), (18, 15), (18, 30)):
|
||||||
|
slots.append(
|
||||||
|
PlanningSlot(
|
||||||
|
interval_start=datetime(2026, 5, 26, h, m, tzinfo=prague),
|
||||||
|
buy_price=5.0,
|
||||||
|
sell_price=4.0 + 0.1 * (h - 18),
|
||||||
|
pv_a_forecast_w=0,
|
||||||
|
pv_b_forecast_w=0,
|
||||||
|
load_baseline_w=800,
|
||||||
|
ev1_connected=False,
|
||||||
|
ev2_connected=False,
|
||||||
|
allow_discharge_export=True,
|
||||||
|
charge_acquisition_buy_czk_kwh=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(_evening_push_calendar_segments(slots, set(range(len(slots))))), 2)
|
||||||
|
n = len(slots)
|
||||||
|
budget_segs = _evening_push_soc_budget_calendar_segments(
|
||||||
|
slots, discharge_export_ok=set(range(n))
|
||||||
|
)
|
||||||
|
self.assertEqual(len(budget_segs), 1)
|
||||||
|
self.assertTrue(all(slots[t].interval_start.day == 25 for seg in budget_segs for t in seg))
|
||||||
|
primary = _primary_night_export_segment_indices(slots)
|
||||||
|
self.assertEqual(primary, {0, 1, 2})
|
||||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||||
per_slot = 13_500 * 0.95 * 0.25
|
per_slot = 13_500 * 0.95 * 0.25
|
||||||
push = _evening_battery_export_push_indices(
|
push = _evening_battery_export_push_indices(
|
||||||
@@ -318,24 +356,25 @@ class EveningPushBudgetTests(unittest.TestCase):
|
|||||||
soc_max_wh=bat.soc_max_wh,
|
soc_max_wh=bat.soc_max_wh,
|
||||||
per_slot_discharge_wh=per_slot,
|
per_slot_discharge_wh=per_slot,
|
||||||
discharge_slot_buffer=1.5,
|
discharge_slot_buffer=1.5,
|
||||||
discharge_export_ok=set(range(len(slots))),
|
discharge_export_ok=set(range(n)),
|
||||||
)
|
)
|
||||||
day25 = {t for t in push if slots[t].interval_start.day == 25}
|
day25 = {t for t in push if slots[t].interval_start.day == 25}
|
||||||
day26 = {t for t in push if slots[t].interval_start.day == 26}
|
day26 = {t for t in push if slots[t].interval_start.day == 26}
|
||||||
self.assertGreaterEqual(len(day25), 1)
|
self.assertGreaterEqual(len(day25), 1)
|
||||||
self.assertGreaterEqual(len(day26), 1)
|
self.assertEqual(day26, set(), "zítřejší večer nesmí krást dnešní Wh rozpočet")
|
||||||
|
|
||||||
def test_evening_push_budget_matches_r063_formula(self) -> None:
|
def test_evening_push_budget_matches_r063_formula(self) -> None:
|
||||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||||
soc = 0.85 * bat.soc_max_wh
|
soc = 0.85 * bat.soc_max_wh
|
||||||
|
floor = max(bat.min_soc_wh, bat.reserve_soc_wh)
|
||||||
budget = _evening_push_discharge_budget_wh(
|
budget = _evening_push_discharge_budget_wh(
|
||||||
current_soc_wh=soc,
|
current_soc_wh=soc,
|
||||||
min_soc_wh=bat.min_soc_wh,
|
discharge_floor_wh=floor,
|
||||||
soc_max_wh=bat.soc_max_wh,
|
soc_max_wh=bat.soc_max_wh,
|
||||||
discharge_slot_buffer=1.5,
|
discharge_slot_buffer=1.5,
|
||||||
)
|
)
|
||||||
exportable_full = bat.soc_max_wh - bat.min_soc_wh
|
exportable_full = bat.soc_max_wh - floor
|
||||||
available = soc - bat.min_soc_wh
|
available = soc - floor
|
||||||
self.assertAlmostEqual(budget, min(available, exportable_full * 1.5))
|
self.assertAlmostEqual(budget, min(available, exportable_full * 1.5))
|
||||||
|
|
||||||
def test_push_slot_count_follows_wh_budget_not_fixed_top_n(self) -> None:
|
def test_push_slot_count_follows_wh_budget_not_fixed_top_n(self) -> None:
|
||||||
@@ -360,26 +399,27 @@ class EveningPushBudgetTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
bat = _battery(uc_wh=64_000.0, min_pct=10.0, max_pct=95.0)
|
||||||
per_slot = 17_000 * 0.95 * 0.25
|
per_slot = 17_000 * 0.95 * 0.25
|
||||||
soc_three_slots = bat.min_soc_wh + 3.2 * per_slot
|
floor = bat.min_soc_wh
|
||||||
|
soc_for_budget = floor + 3.2 * per_slot
|
||||||
budget = _evening_push_discharge_budget_wh(
|
budget = _evening_push_discharge_budget_wh(
|
||||||
current_soc_wh=soc_three_slots,
|
current_soc_wh=soc_for_budget,
|
||||||
min_soc_wh=bat.min_soc_wh,
|
discharge_floor_wh=floor,
|
||||||
soc_max_wh=bat.soc_max_wh,
|
soc_max_wh=bat.soc_max_wh,
|
||||||
discharge_slot_buffer=1.5,
|
discharge_slot_buffer=1.5,
|
||||||
)
|
)
|
||||||
expected_n = min(3, max(0, int(budget // per_slot)))
|
expected_n = max(0, int(budget // per_slot))
|
||||||
push = _evening_battery_export_push_indices(
|
push = _evening_battery_export_push_indices(
|
||||||
slots,
|
slots,
|
||||||
charge_acquisition_czk_kwh=0.5,
|
charge_acquisition_czk_kwh=0.5,
|
||||||
degrad_czk_kwh=0.15,
|
degrad_czk_kwh=0.15,
|
||||||
current_soc_wh=soc_three_slots,
|
current_soc_wh=soc_for_budget,
|
||||||
min_soc_wh=bat.min_soc_wh,
|
min_soc_wh=floor,
|
||||||
soc_max_wh=bat.soc_max_wh,
|
soc_max_wh=bat.soc_max_wh,
|
||||||
per_slot_discharge_wh=per_slot,
|
per_slot_discharge_wh=per_slot,
|
||||||
discharge_slot_buffer=1.5,
|
discharge_slot_buffer=1.5,
|
||||||
)
|
)
|
||||||
self.assertEqual(len(push), expected_n)
|
self.assertEqual(len(push), expected_n)
|
||||||
self.assertEqual(push, [0, 1, 2], "nejdražší sloty první, ne jeden slot")
|
self.assertEqual(push[:3], [0, 1, 2], "nejdražší sloty první")
|
||||||
self.assertNotIn(3, push)
|
self.assertNotIn(3, push)
|
||||||
# Více SoC → více push slotů (dynamicky, ne strop 3).
|
# Více SoC → více push slotů (dynamicky, ne strop 3).
|
||||||
push_hi = _evening_battery_export_push_indices(
|
push_hi = _evening_battery_export_push_indices(
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ flowchart TD
|
|||||||
- v **celém nočním okně** pro **všechny** sloty s `allow_discharge_export` **mimo** `evening_push_ts` (výjimky: pre-neg / neg-evening větve);
|
- v **celém nočním okně** pro **všechny** sloty s `allow_discharge_export` **mimo** `evening_push_ts` (výjimky: pre-neg / neg-evening větve);
|
||||||
- **nezakazuje** přebytek FVE do sítě (`ge_pv`).
|
- **nezakazuje** přebytek FVE do sítě (`ge_pv`).
|
||||||
|
|
||||||
3. **v43 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`):
|
3. **v43 / v49 — večerní push + nocí vlastní spotřeba + odpolední arbitráž** (`evening_push_ts`):
|
||||||
- push jen **≥17h Prague** + `allow_discharge_export`; rozpočet Wh **per kalendářní večer** (druhý den v horizontu ne prázdný);
|
- push jen **≥17h Prague** + `allow_discharge_export`; **v49:** rozpočet Wh z **aktuální SoC** jen pro **první noční epizodu** v horizontu (dnes večer → ráno), **ne** dělení se zítřejším večerem — zítřek přidá vlastní rolling replan po FVE/neg dni;
|
||||||
- mimo push: **`night_self_consume_discourage`** — baterie krmí dům, ne import ~5 Kč/kWh;
|
- mimo push: **`night_self_consume_discourage`** — baterie krmí dům, ne import ~5 Kč/kWh;
|
||||||
- **R__063 `evening_arbitrage_unlock`:** grid nabíjení **11–16h** jen na dnech **bez sell<0**, když večerní peak sell > buy + degrad;
|
- **R__063 `evening_arbitrage_unlock`:** grid nabíjení **11–16h** jen na dnech **bez sell<0**, když večerní peak sell > buy + degrad;
|
||||||
- **bez predawn push** (02–06h); **`peak_export_shortfall`** v noci vypnutý.
|
- **bez predawn push** (02–06h); **`peak_export_shortfall`** v noci vypnutý.
|
||||||
|
|||||||
@@ -5,6 +5,34 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-05-31 — Večerní push: celý Wh rozpočet jen pro dnešní noc (v49)
|
||||||
|
|
||||||
|
**Problém (v43):** `push_budget / počet_kalendářních_večerů` dělil **aktuální SoC** mezi dnešní a **zítřejší** večer v horizontu — přes den FVE / neg nabíjení. Dnes večer dostal ~polovinu rozpočtu → chyběly sloty (např. 23:15); zítra večer push z dnešní SoC nedává smysl.
|
||||||
|
|
||||||
|
**Změna (v49):**
|
||||||
|
- **`_primary_night_export_segment_indices`** — první noční epizoda (17h → východ FVE) od začátku horizontu.
|
||||||
|
- **`_evening_push_soc_budget_calendar_segments`** — push Wh jen pro kalendářní večer v této epizodě; **jeden společný** rozpočet, kandidáti **sell desc** přes zbývající sloty.
|
||||||
|
- **Hysteréze** (`_rolling_evening_push_override`): drží jen sloty z budget-eligible množiny.
|
||||||
|
|
||||||
|
Tag **`2026-05-31-evening-push-budget-primary-night-v49`**. Zítřejší večer → vlastní rolling replan po dni.
|
||||||
|
|
||||||
|
**Ověření:** `pytest … -k evening_push_budget_only_primary`; MCP: `planner_build_tag` v49, `evening_push_ts` bez zítřejších 18:30+ při replanu dnes večer; více dnešních push slotů při stejné SoC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-05-31 — Podlaha vývoje reserve 20 %, žádný curtail slabé FVE za úsvitu (v48)
|
||||||
|
|
||||||
|
**Problém (běh 20728, v47):** Večer + **03:00–03:15** ranní peak export → SoC **~13,5 %** (pod **reserve 20 %**). **05:15–06:00 Prague** (= 03:15–03:45 UTC) plán **řeže celou PV A** (`curt_a = pv_a` při ~86–346 W) — `ge_pv=0` kvůli `sell < future_sell` (večerní peak v horizontu).
|
||||||
|
|
||||||
|
**Změna (v48):**
|
||||||
|
- Rozpočet push + podlaha SoC: **`reserve_soc_wh`**, ne `min_soc_wh` (10 %).
|
||||||
|
- Ranní peak export: **`soc[t] ≥ reserve`** v peak slotu.
|
||||||
|
- **`DAWN_LOW_PV_NO_CURTAIL_W`:** při `sell≥0` a `pv_a < 1500 W` neblokovat `ge_pv` (žádný úsvitní curtail).
|
||||||
|
|
||||||
|
Tag **`2026-05-31-reserve-floor-no-dawn-curtail-v48`**. Pravidlo agenta: `.cursor/rules/ems-planning-agent-discipline.mdc`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-05-30 — Po večerním pushu noc z baterie, ne import za 5 Kč (v47)
|
## 2026-05-30 — Po večerním pushu noc z baterie, ne import za 5 Kč (v47)
|
||||||
|
|
||||||
**Záměr uživatele:** Večerní vývoz za **~3 Kč/kWh** (sell<buy) je **správně** — vyprázdnění před neg dnem/FVE. Špatně je **po pushu držet SoC a kupovat dům za ~5 Kč**.
|
**Záměr uživatele:** Večerní vývoz za **~3 Kč/kWh** (sell<buy) je **správně** — vyprázdnění před neg dnem/FVE. Špatně je **po pushu držet SoC a kupovat dům za ~5 Kč**.
|
||||||
|
|||||||
Reference in New Issue
Block a user