cileni k vybiti pred ranem kdy nabiju z fve
This commit is contained in:
@@ -71,11 +71,13 @@ NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
|
||||
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
|
||||
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
|
||||
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
|
||||
PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36"
|
||||
PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36b"
|
||||
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
|
||||
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
|
||||
# Večer před neg dnem: výboj směrem k soc_need na začátku zítřejšího sell<0 okna.
|
||||
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 70.0
|
||||
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
|
||||
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
|
||||
# Kotva: SoC na konci večera D−1 ≤ reserve_soc (+ slack) před ranním sell<0 dnem D.
|
||||
NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 4.0
|
||||
# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl.
|
||||
PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15
|
||||
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0
|
||||
@@ -1248,6 +1250,42 @@ def _evening_discharge_before_neg_day_ts(
|
||||
return out
|
||||
|
||||
|
||||
def _neg_evening_reserve_soc_anchors(
|
||||
slots: list[PlanningSlot],
|
||||
neg_sell_day_meta: dict[str, Any],
|
||||
battery: Any,
|
||||
) -> list[tuple[int, float]]:
|
||||
"""
|
||||
Poslední večerní slot kalendářního dne D−1 před dnem D s sell<0: cíl SoC ≤ reserve_soc.
|
||||
Headroom pro ranní nabíjení z FVE / levného spotu v neg okně (ne držet 60 %+ přes noc).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
reserve_wh = float(
|
||||
getattr(battery, "reserve_soc_wh", getattr(battery, "min_soc_wh", 0.0))
|
||||
)
|
||||
out: list[tuple[int, float]] = []
|
||||
for day_info in neg_sell_day_meta.get("days") or []:
|
||||
first_neg = int(day_info.get("first_neg_idx", -1))
|
||||
if first_neg < 0 or first_neg >= len(slots):
|
||||
continue
|
||||
neg_date = _prague_calendar_date(slots[first_neg])
|
||||
prev_date = neg_date - timedelta(days=1)
|
||||
anchors = [
|
||||
t
|
||||
for t, st in enumerate(slots)
|
||||
if _prague_calendar_date(st) == prev_date
|
||||
and (
|
||||
17 <= _prague_hour(st) <= 23
|
||||
or _in_night_battery_export_window(st)
|
||||
)
|
||||
]
|
||||
if not anchors:
|
||||
continue
|
||||
out.append((max(anchors), reserve_wh))
|
||||
return out
|
||||
|
||||
|
||||
MORNING_PRENEG_START_HOUR = 5
|
||||
MORNING_PRENEG_END_HOUR = 11
|
||||
|
||||
@@ -2043,6 +2081,7 @@ def solve_dispatch(
|
||||
pre_neg_cushion_by_day: dict[str, bool] = {}
|
||||
pre_neg_pv_export_ts: set[int] = set()
|
||||
neg_evening_before_neg_ts: set[int] = set()
|
||||
neg_evening_reserve_anchors: list[tuple[int, float]] = []
|
||||
if om == "AUTO" and not purchase_fixed_pre and neg_sell_phases_en:
|
||||
pre_neg_pv_export_ts, pre_neg_cushion_by_day = _pre_neg_pv_export_bundle(
|
||||
slots,
|
||||
@@ -2056,6 +2095,11 @@ def solve_dispatch(
|
||||
slots,
|
||||
neg_sell_day_meta,
|
||||
)
|
||||
neg_evening_reserve_anchors = _neg_evening_reserve_soc_anchors(
|
||||
slots,
|
||||
neg_sell_day_meta,
|
||||
battery,
|
||||
)
|
||||
elif om == "AUTO" and not purchase_fixed_pre:
|
||||
legacy_ok = bool(
|
||||
first_neg_sell_idx is not None
|
||||
@@ -2247,6 +2291,7 @@ def solve_dispatch(
|
||||
pre_neg_pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
pre_neg_pv_export_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
neg_evening_before_neg_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
neg_evening_reserve_soc_slack: 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, float]] = []
|
||||
neg_buy_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = []
|
||||
@@ -2371,6 +2416,17 @@ def solve_dispatch(
|
||||
export_cap_evening,
|
||||
)
|
||||
neg_evening_before_neg_shortfall.append((t_ev, sf_ev, export_cap_evening))
|
||||
for t_anchor, reserve_tgt in neg_evening_reserve_anchors:
|
||||
slack_cap = max(
|
||||
0.0,
|
||||
float(battery.soc_max_wh) - float(reserve_tgt),
|
||||
)
|
||||
sl = pulp.LpVariable(
|
||||
f"neg_eve_reserve_soc_slack_{t_anchor}",
|
||||
0,
|
||||
slack_cap,
|
||||
)
|
||||
neg_evening_reserve_soc_slack.append((t_anchor, sl, float(reserve_tgt)))
|
||||
for t in range(T):
|
||||
if not post_neg_pv_topup[t]:
|
||||
continue
|
||||
@@ -2599,6 +2655,10 @@ def solve_dispatch(
|
||||
/ 1000.0
|
||||
for _t, sf, _cap in neg_evening_before_neg_shortfall
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
sl * NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH
|
||||
for _t, sl, _tgt in neg_evening_reserve_soc_slack
|
||||
)
|
||||
+ pulp.lpSum(
|
||||
bc_pv[t]
|
||||
* PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH
|
||||
@@ -2701,6 +2761,8 @@ def solve_dispatch(
|
||||
prob += sf >= cap_w - ge_pv[t_sf]
|
||||
for t_sf, sf, cap_w in neg_evening_before_neg_shortfall:
|
||||
prob += sf >= cap_w - ge_bat[t_sf]
|
||||
for t_sl, sl, reserve_tgt in neg_evening_reserve_soc_slack:
|
||||
prob += soc[t_sl] <= float(reserve_tgt) + sl
|
||||
preneg_export_min_soc_wh = float(min_soc_wh) + max(
|
||||
float(battery.max_discharge_power_w)
|
||||
* float(battery.discharge_efficiency)
|
||||
@@ -3543,6 +3605,11 @@ def solve_dispatch(
|
||||
"neg_evening_before_neg": (
|
||||
t in neg_evening_before_neg_ts if neg_sell_phases_en else None
|
||||
),
|
||||
"neg_evening_reserve_anchor": (
|
||||
any(t == ta for ta, _ in neg_evening_reserve_anchors)
|
||||
if neg_sell_phases_en
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
tgt_s = st.safety_soc_target_wh if daytime_en else None
|
||||
@@ -3663,6 +3730,13 @@ def solve_dispatch(
|
||||
slots[i].interval_start.isoformat()
|
||||
for i in sorted(neg_evening_before_neg_ts)
|
||||
],
|
||||
"neg_evening_reserve_soc_anchors": [
|
||||
{
|
||||
"slot": slots[t_a].interval_start.isoformat(),
|
||||
"target_reserve_soc_wh": float(tgt_wh),
|
||||
}
|
||||
for t_a, tgt_wh in neg_evening_reserve_anchors
|
||||
],
|
||||
"neg_sell_prep_window_v36": bool(neg_sell_phases_en),
|
||||
"neg_sell_day_pv_usable_wh": (
|
||||
_neg_sell_day_pv_usable_wh(
|
||||
|
||||
@@ -19,7 +19,9 @@ from services.planning_engine import (
|
||||
_in_night_battery_export_window,
|
||||
_neg_sell_day_phases,
|
||||
_neg_sell_phases_enabled,
|
||||
_neg_evening_reserve_soc_anchors,
|
||||
_pre_neg_pv_export_bundle,
|
||||
_prague_calendar_date,
|
||||
_pre_neg_buy_soc_ceiling_wh,
|
||||
_pre_neg_peak_sell_idx,
|
||||
_pre_neg_pv_export_forecast_cushion_ok,
|
||||
@@ -4093,6 +4095,52 @@ class NegSellPrepWindowV36Tests(unittest.TestCase):
|
||||
"morning before 2nd neg day should allow pre-neg export",
|
||||
)
|
||||
|
||||
def test_evening_reserve_anchor_before_neg_day(self) -> None:
|
||||
base = datetime(2026, 6, 10, 10, 0, tzinfo=ZoneInfo("Europe/Prague")).astimezone(
|
||||
timezone.utc
|
||||
)
|
||||
slots: list[PlanningSlot] = []
|
||||
for i in range(120):
|
||||
local = (base + timedelta(minutes=15 * i)).astimezone(
|
||||
ZoneInfo("Europe/Prague")
|
||||
)
|
||||
h = local.hour + local.minute / 60.0
|
||||
if local.date().day == 10:
|
||||
sell = -0.2 if h >= 14 else 2.5
|
||||
elif local.date().day == 11:
|
||||
sell = -0.2 if 9 <= h < 15 else 2.8
|
||||
else:
|
||||
sell = 2.5
|
||||
slots.append(
|
||||
PlanningSlot(
|
||||
interval_start=base + timedelta(minutes=15 * i),
|
||||
buy_price=2.0,
|
||||
sell_price=sell,
|
||||
pv_a_forecast_w=3000,
|
||||
pv_b_forecast_w=3000,
|
||||
load_baseline_w=1500,
|
||||
ev1_connected=False,
|
||||
ev2_connected=False,
|
||||
allow_charge=True,
|
||||
allow_discharge_export=True,
|
||||
)
|
||||
)
|
||||
bat = _battery(uc_wh=64_000.0, max_pct=95.0, arb_pct=20.0)
|
||||
bat.planner_neg_sell_prep_soc_percent = 80.0
|
||||
bat.planner_neg_sell_full_soc_tail_slots = 4
|
||||
_ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat)
|
||||
anchors = _neg_evening_reserve_soc_anchors(slots, meta, bat)
|
||||
self.assertGreaterEqual(len(anchors), 1)
|
||||
t_a, tgt = anchors[0]
|
||||
self.assertAlmostEqual(tgt, bat.reserve_soc_wh, delta=100.0)
|
||||
self.assertEqual(_prague_calendar_date(slots[t_a]).day, 10)
|
||||
# Kotva pro den 11: večer 10.6. (i když odpoledne 10.6. už bylo sell<0).
|
||||
if len(meta["days"]) >= 2:
|
||||
day11_first = int(meta["days"][1]["first_neg_idx"])
|
||||
prev = _prague_calendar_date(slots[day11_first]) - timedelta(days=1)
|
||||
a11 = [(t, w) for t, w in anchors if _prague_calendar_date(slots[t]) == prev]
|
||||
self.assertEqual(len(a11), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -38,7 +38,7 @@ Kompletní návrh: [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/
|
||||
- [x] **TUV — večerní doklep** — **19:00** Europe/Prague (rozhodnuto 2026-05); implementace v **v36**; doplnit `tuv_comfort_temp_c` / `tuv_preheat_temp_c` do konfigurace site.
|
||||
- [ ] **Vizualizace flexibilních zátěží v UI** — **probrat a navrhnout před v37+** (neimplementovat bazén/TČ sink do FE naslepo). Viz [`planning-neg-sell-strategy.md` § 9.1](04-modules/planning-neg-sell-strategy.md). Návrhy k diskusi: pásma dne (pre-neg / sell<0 / bod **T**), rozpočet hodin bazénu vs. `E_surplus_after_t`, slotový rozpad `hp` / EV / (budoucí pool), srovnání běhů plánu.
|
||||
- [x] **v35 implementace** — rampa B, **t_detach**, `E_surplus_after_t` (`2026-05-28-neg-sell-b-ramp-v35`).
|
||||
- [x] **v36 prep okno** — oprava **T**, pre-neg **per den** (cushion A+B), večerní výboj před neg dnem (`2026-05-28-neg-prep-window-v36`).
|
||||
- [x] **v36 prep okno** — oprava **T**, pre-neg **per den** (cushion A+B), večerní výboj před neg dnem (`2026-05-28-neg-prep-window-v36b`, kotva **reserve_soc**).
|
||||
- [ ] **v36 termika** — blok TČ v pre-neg exportu, TUV po **T**, doklep **19:00** (zatím jen plán).
|
||||
|
||||
#### Roadmap (pořadí)
|
||||
|
||||
@@ -11,13 +11,15 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
|
||||
|
||||
**Rozhodnutí home-01** (souhrn v [`docs/06-open-questions.md`](06-open-questions.md)): rampa/**T** odvozené z PV B (bez fixních 80 % v LP); TČ ne v pre-neg exportu; bazén min 4 h/den + Shelly; spirála Loxone; **workshop UI flex zátěží před v37** (§ 9.1 strategie).
|
||||
|
||||
## 2026-05-28 — Přípravné okno neg dne (v36)
|
||||
## 2026-05-28 — Přípravné okno neg dne (v36 / v36b)
|
||||
|
||||
**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36`.
|
||||
**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36b`.
|
||||
|
||||
**Změna:** (1) **Bod T** — oprava: `t_detach` až když `soc_need[t] ≥ 85 % soc_max` a suffix B pokryje zbytek (ne hned na 1. neg slotu). (2) **Pre-neg per pražský den** — `_pre_neg_pv_export_bundle`, cushion **A+B** v neg okně dne; ráno před každým `sell<0` dnem export FVE pokud cushion OK. (3) **Večer D−1** — `_evening_discharge_before_neg_day_ts` + výboj před neg dnem.
|
||||
**Změna (v36):** Bod **T**, pre-neg per den (cushion A+B), večerní `neg_evening_before_neg_slots`.
|
||||
|
||||
**Ověření:** `NegSellPrepWindowV36Tests`, `test_t_detach_not_first_neg_on_long_sunny_day`; MCP `solver_params.inputs.pre_neg_cushion_by_day`, `t_detach_idx` pro 27. 5.
|
||||
**Změna (v36b):** Kotva **`neg_evening_reserve_soc_anchors`** — SoC na konci večera D−1 ≤ **`reserve_soc_wh`** (+ slack, penalizace 4 Kč/Wh). Důvod: evening push jen na peak sell nechal ~60 % SoC přes noc místo headroomu pro ranní neg okno.
|
||||
|
||||
**Ověření:** `test_evening_reserve_anchor_before_neg_day`; MCP `solver_params.inputs.neg_evening_reserve_soc_anchors`.
|
||||
|
||||
## 2026-05-28 — Rampa SoC z PV B, bod T (v35)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user