From d1ba864fc03d87c122ff17cd0f2d151a31681130 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Tue, 26 May 2026 14:09:19 +0200 Subject: [PATCH] oprava ranniho nabijeni a oprava bodu T --- backend/services/planning_engine.py | 364 ++++++++++++++---- backend/tests/test_planning_dispatch_milp.py | 75 ++++ docs/04-modules/planning-neg-sell-strategy.md | 35 +- docs/04-modules/planning.md | 2 +- docs/06-open-questions.md | 7 +- docs/planning-changelog.md | 8 + 6 files changed, 415 insertions(+), 76 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 4e02add..336beb0 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,9 +71,11 @@ 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-sell-b-ramp-v35" +PLANNER_BUILD_TAG = "2026-05-28-neg-prep-window-v36" # 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 # 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 @@ -823,6 +825,59 @@ def _neg_sell_phases_enabled(battery: Any) -> bool: return prep_pct < 100.0 - 1e-6 and tail_slots > 0 +def _neg_sell_indices_by_prague_day( + slots: list[PlanningSlot], +) -> dict[object, list[int]]: + by_day: dict[object, list[int]] = {} + for t, st in enumerate(slots): + if float(st.sell_price) < 0.0: + by_day.setdefault(_prague_calendar_date(st), []).append(t) + for day in by_day: + by_day[day].sort() + return by_day + + +def _neg_sell_t_detach_index( + indices: list[int], + charge_b: dict[int, float], + soc_need: dict[int, float], + tail_start: int, + soc_max: float, + *, + margin: float = 1.05, + min_gap_wh: float = 500.0, + detach_soc_frac: float = 0.85, +) -> int: + """ + Bod T: první prep slot, kde (1) soc_need[t] ≥ detach_soc_frac × soc_max a + (2) zbývající B-nabití od t do konce pokryje mezeru do 100 %. + Dřívější chyba: soc_need[t] ≤ soc_need[tail_start] platilo hned na začátku okna. + """ + if not indices: + return 0 + suffix_from: dict[int, float] = {} + run = 0.0 + for t in reversed(indices): + run += float(charge_b.get(t, 0.0)) + suffix_from[t] = run + thresh_wh = max( + soc_max * detach_soc_frac, + float(soc_need.get(tail_start, soc_max)) * 0.92, + ) + for t in indices: + if t >= tail_start: + continue + need_t = float(soc_need.get(t, soc_max)) + if need_t < thresh_wh: + continue + gap_rem = soc_max - need_t + if gap_rem <= min_gap_wh: + return t + if suffix_from.get(t, 0.0) >= gap_rem * margin: + return t + return tail_start + + def _neg_sell_pv_b_charge_wh(slot: PlanningSlot, battery: Any) -> float: """Odhad Wh nabitelné jen z PV B v jednom sell<0 slotu (surplus nad load, cap výkonu).""" pv_surplus_b = max(0.0, float(slot.pv_b_forecast_w) - float(slot.load_baseline_w)) @@ -888,7 +943,8 @@ def _neg_sell_day_phases( ) -> tuple[list[str], list[Optional[float]], list[float], dict[str, Any]]: """ Per slot: phase (none|prep|tail), soc_target_wh (rampa z PV B, ne fixní %), shortfall váha. - V35: zpětná projekce soc_need z B od tail; t_detach = první prep kde soc_need ≤ soc_need[tail_start]. + V35: zpětná projekce soc_need z B od tail. + V36: t_detach = první prep slot kde suffix B-nabití pokryje (soc_max − soc_need[t]). """ t_len = len(slots) phases: list[str] = ["none"] * t_len @@ -918,14 +974,14 @@ def _neg_sell_day_phases( t_prev = indices[i - 1] soc_need[t_prev] = max(min_soc, soc_need[t_cur] - charge_b[t_cur]) - soc_detach_wh = float(soc_need.get(tail_start, soc_max)) - t_detach = tail_start - for t in indices: - if t >= tail_start: - continue - if soc_need[t] <= soc_detach_wh + 1e-3: - t_detach = t - break + t_detach = _neg_sell_t_detach_index( + indices, + charge_b, + soc_need, + tail_start, + soc_max, + ) + soc_detach_wh = float(soc_need.get(t_detach, soc_max)) e_surplus = _neg_sell_e_surplus_after_t_wh(slots, t_detach, last_t, battery) @@ -969,6 +1025,7 @@ def _neg_sell_day_phases( meta: dict[str, Any] = { "neg_sell_b_ramp_v35": True, + "neg_sell_prep_window_v36": True, "days": day_meta, "post_detach_prep_ts": sorted(post_detach_prep_ts), } @@ -1010,6 +1067,43 @@ def _neg_sell_day_pv_usable_wh( return total_wh +def _pre_neg_pv_export_forecast_cushion_ok_for_day( + slots: list[PlanningSlot], + battery: Any, + first_neg_t: int, + soc_at_day_start_wh: float, + *, + neg_sell_phases_en: bool, + soc_target_by_t: list[Optional[float]] | None = None, +) -> bool: + """ + Cushion pro jeden pražský den: usable A+B v sell<0 okně pokryje dobítí na soc_need[first_neg]. + """ + if first_neg_t < 0 or first_neg_t >= len(slots): + return False + if neg_sell_phases_en and soc_target_by_t is not None: + tgt = soc_target_by_t[first_neg_t] + target_wh = float(tgt) if tgt is not None else float(battery.soc_max_wh) + usable_wh = _neg_sell_day_pv_usable_wh( + slots, + first_neg_t, + max_charge_power_w=float(battery.max_charge_power_w), + charge_efficiency=float(battery.charge_efficiency), + ) + else: + target_wh = float(battery.soc_max_wh) + usable_wh = _neg_sell_day_pv_usable_wh( + slots, + first_neg_t, + max_charge_power_w=float(battery.max_charge_power_w), + charge_efficiency=float(battery.charge_efficiency), + ) + needed_wh = max(0.0, target_wh - float(soc_at_day_start_wh)) + if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH: + return True + return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN + + def _pre_neg_pv_export_forecast_cushion_ok( slots: list[PlanningSlot], battery: Any, @@ -1017,30 +1111,90 @@ def _pre_neg_pv_export_forecast_cushion_ok( first_neg_sell_idx: int | None, *, neg_sell_phases_en: bool, + soc_target_by_t: list[Optional[float]] | None = None, ) -> bool: - """ - Export FVE před sell<0 jen pokud forecast B v sell<0 okně pokryje dobítí na soc_need z rampy. - Jinak raději nabíjet teď — riziko deště / podhodnocené FVE v sell<0. - """ + """Zpětná kompatibilita: cushion pro první sell<0 v horizontu.""" if first_neg_sell_idx is None or first_neg_sell_idx <= 0: return False - if neg_sell_phases_en: + targets = soc_target_by_t + if neg_sell_phases_en and targets is None: _ph, targets, _w, _meta = _neg_sell_day_phases(slots, battery) - tgt = targets[first_neg_sell_idx] - target_wh = float(tgt) if tgt is not None else float(battery.soc_max_wh) - usable_wh = _neg_sell_day_pv_b_usable_wh(slots, first_neg_sell_idx, battery) - else: - target_wh = float(battery.soc_max_wh) - usable_wh = _neg_sell_day_pv_usable_wh( + return _pre_neg_pv_export_forecast_cushion_ok_for_day( + slots, + battery, + first_neg_sell_idx, + current_soc_wh, + neg_sell_phases_en=neg_sell_phases_en, + soc_target_by_t=targets, + ) + + +def _pre_neg_pv_export_slot_indices_for_day( + slots: list[PlanningSlot], + first_neg_t: int, + first_neg_buy_idx: int | None, +) -> set[int]: + """Kladný sell téhož dne před prvním sell<0, PV přebytek.""" + if first_neg_t <= 0: + return set() + neg_day = _prague_calendar_date(slots[first_neg_t]) + out: set[int] = set() + for t in range(first_neg_t): + if _prague_calendar_date(slots[t]) != neg_day: + continue + if float(slots[t].sell_price) < 0.0: + continue + if first_neg_buy_idx is not None and t >= first_neg_buy_idx: + continue + if _slot_pv_surplus_w(slots[t]) <= NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W: + continue + out.add(t) + return out + + +def _pre_neg_pv_export_bundle( + slots: list[PlanningSlot], + battery: Any, + current_soc_wh: float, + first_neg_buy_idx: int | None, + *, + neg_sell_phases_en: bool, + soc_target_by_t: list[Optional[float]] | None = None, +) -> tuple[set[int], dict[str, bool]]: + """ + v36: pre-neg export per pražský den s vlastním cushion (A+B v neg okně dne). + """ + by_day = _neg_sell_indices_by_prague_day(slots) + export_ts: set[int] = set() + cushion_by_day: dict[str, bool] = {} + soc_est = float(current_soc_wh) + for day in sorted(by_day.keys()): + indices = by_day[day] + if not indices: + continue + first_t = indices[0] + ok = _pre_neg_pv_export_forecast_cushion_ok_for_day( slots, - first_neg_sell_idx, - max_charge_power_w=float(battery.max_charge_power_w), - charge_efficiency=float(battery.charge_efficiency), + battery, + first_t, + soc_est, + neg_sell_phases_en=neg_sell_phases_en, + soc_target_by_t=soc_target_by_t, ) - needed_wh = max(0.0, target_wh - float(current_soc_wh)) - if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH: - return True - return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN + cushion_by_day[str(day)] = ok + if ok: + export_ts |= _pre_neg_pv_export_slot_indices_for_day( + slots, + first_t, + first_neg_buy_idx, + ) + tgt0 = ( + float(soc_target_by_t[first_t]) + if soc_target_by_t and soc_target_by_t[first_t] is not None + else float(battery.soc_max_wh) + ) + soc_est = max(float(battery.min_soc_wh), min(float(battery.soc_max_wh), tgt0)) + return export_ts, cushion_by_day def _pre_neg_pv_export_slot_indices( @@ -1049,7 +1203,7 @@ def _pre_neg_pv_export_slot_indices( pre_neg_export_last_t: int | None, first_neg_buy_idx: int | None, ) -> set[int]: - """Sloty s kladným sell před prvním sell<0 (a před buy<0), PV přebytek).""" + """Legacy: jen před globálním prvním sell<0 (v36 preferuj _pre_neg_pv_export_bundle).""" if first_neg_sell_idx is None or pre_neg_export_last_t is None: return set() out: set[int] = set() @@ -1064,6 +1218,36 @@ def _pre_neg_pv_export_slot_indices( return out +def _evening_discharge_before_neg_day_ts( + slots: list[PlanningSlot], + neg_sell_day_meta: dict[str, Any], +) -> set[int]: + """ + Večer/noc kalendářního dne D−1 před pražským dnem D s sell<0: příprava headroomu. + """ + from datetime import timedelta + + out: set[int] = set() + 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) + for t, st in enumerate(slots): + if _prague_calendar_date(st) != prev_date: + continue + if float(st.sell_price) < 0.0: + continue + h = _prague_hour(st) + if not (17 <= h <= 23 or _in_night_battery_export_window(st)): + continue + if float(st.sell_price) < PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH: + continue + out.add(t) + return out + + MORNING_PRENEG_START_HOUR = 5 MORNING_PRENEG_END_HOUR = 11 @@ -1856,29 +2040,42 @@ def solve_dispatch( prep_hold_bcpv_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] prep_hold_curtail_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] prep_hold_met_binary: dict[int, pulp.LpVariable] = {} - pre_neg_pv_export_forecast_ok = bool( - om == "AUTO" - and not purchase_fixed_pre - and first_neg_sell_idx is not None - and pre_neg_export_last_t is not None - and _pre_neg_pv_export_forecast_cushion_ok( + pre_neg_cushion_by_day: dict[str, bool] = {} + pre_neg_pv_export_ts: set[int] = set() + neg_evening_before_neg_ts: set[int] = set() + 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, battery, current_soc_wh, - first_neg_sell_idx, - neg_sell_phases_en=neg_sell_phases_en, - ) - ) - pre_neg_pv_export_ts = ( - _pre_neg_pv_export_slot_indices( - slots, - first_neg_sell_idx, - pre_neg_export_last_t, first_neg_buy_idx, + neg_sell_phases_en=True, + soc_target_by_t=neg_sell_soc_target_by_t, ) - if pre_neg_pv_export_forecast_ok - else set() - ) + neg_evening_before_neg_ts = _evening_discharge_before_neg_day_ts( + slots, + neg_sell_day_meta, + ) + elif om == "AUTO" and not purchase_fixed_pre: + legacy_ok = bool( + first_neg_sell_idx is not None + and pre_neg_export_last_t is not None + and _pre_neg_pv_export_forecast_cushion_ok( + slots, + battery, + current_soc_wh, + first_neg_sell_idx, + neg_sell_phases_en=False, + ) + ) + if legacy_ok: + pre_neg_pv_export_ts = _pre_neg_pv_export_slot_indices( + slots, + first_neg_sell_idx, + pre_neg_export_last_t, + first_neg_buy_idx, + ) + pre_neg_pv_export_forecast_ok = bool(pre_neg_pv_export_ts) pre_neg_buy_discharge_ts: set[int] = set() if om == "AUTO" and first_neg_buy_idx is not None and first_neg_buy_idx > 0: pre_neg_buy_discharge_ts = _pre_neg_buy_discharge_indices( @@ -2049,6 +2246,7 @@ def solve_dispatch( pv_charge_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] 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_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]] = [] @@ -2138,25 +2336,41 @@ def solve_dispatch( cap_ns = float(min(pv_surplus_ns, battery.max_charge_power_w)) sf_ns = pulp.LpVariable(f"neg_phase_pv_charge_{t_ns}", 0, cap_ns) pv_charge_shortfall.append((t_ns, sf_ns, cap_ns)) - if pre_neg_pv_export_forecast_ok: - for t_pe in sorted(pre_neg_pv_export_ts): - s_pe = slots[t_pe] - pv_surplus_pe = max( - 0.0, - float(s_pe.pv_a_forecast_w) - + float(s_pe.pv_b_forecast_w) - - float(s_pe.load_baseline_w), + for t_pe in sorted(pre_neg_pv_export_ts): + s_pe = slots[t_pe] + pv_surplus_pe = max( + 0.0, + float(s_pe.pv_a_forecast_w) + + float(s_pe.pv_b_forecast_w) + - float(s_pe.load_baseline_w), + ) + cap_pe = float( + min( + pv_surplus_pe, + float(grid.max_export_power_w), ) - cap_pe = float( - min( - pv_surplus_pe, - float(grid.max_export_power_w), - ) - ) - if cap_pe <= 500.0: - continue - sf_pe = pulp.LpVariable(f"pre_neg_pv_export_sf_{t_pe}", 0, cap_pe) - pre_neg_pv_export_shortfall.append((t_pe, sf_pe, cap_pe)) + ) + if cap_pe <= 500.0: + continue + sf_pe = pulp.LpVariable(f"pre_neg_pv_export_sf_{t_pe}", 0, cap_pe) + pre_neg_pv_export_shortfall.append((t_pe, sf_pe, cap_pe)) + export_cap_evening = _battery_export_cap_w(battery, grid) + for t_ev in sorted(neg_evening_before_neg_ts): + if t_ev not in discharge_export_slots: + continue + if not _slot_profitable_battery_export( + slots[t_ev], + charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, + min_spread=float(degradation_cost_effective), + fixed_tariff=fixed_tariff_like, + ): + continue + sf_ev = pulp.LpVariable( + f"neg_eve_prep_discharge_{t_ev}", + 0, + export_cap_evening, + ) + neg_evening_before_neg_shortfall.append((t_ev, sf_ev, export_cap_evening)) for t in range(T): if not post_neg_pv_topup[t]: continue @@ -2379,6 +2593,12 @@ def solve_dispatch( sf * PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in pre_neg_pv_export_shortfall ) + + pulp.lpSum( + sf * NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH + * INTERVAL_H + / 1000.0 + for _t, sf, _cap in neg_evening_before_neg_shortfall + ) + pulp.lpSum( bc_pv[t] * PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH @@ -2479,6 +2699,8 @@ def solve_dispatch( prob += sf >= cap_w - bc_pv[t_sf] for t_sf, sf, cap_w in pre_neg_pv_export_shortfall: 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] preneg_export_min_soc_wh = float(min_soc_wh) + max( float(battery.max_discharge_power_w) * float(battery.discharge_efficiency) @@ -3315,6 +3537,12 @@ def solve_dispatch( "neg_sell_post_detach_prep": ( t in neg_sell_post_detach_prep_ts if neg_sell_phases_en else None ), + "pre_neg_pv_export": ( + t in pre_neg_pv_export_ts if neg_sell_phases_en else None + ), + "neg_evening_before_neg": ( + t in neg_evening_before_neg_ts if neg_sell_phases_en else None + ), } ) tgt_s = st.safety_soc_target_wh if daytime_en else None @@ -3427,9 +3655,15 @@ def solve_dispatch( else None ), "pre_neg_pv_export_forecast_ok": bool(pre_neg_pv_export_forecast_ok), + "pre_neg_cushion_by_day": pre_neg_cushion_by_day or None, "pre_neg_pv_export_slots": [ slots[i].interval_start.isoformat() for i in sorted(pre_neg_pv_export_ts) ], + "neg_evening_before_neg_slots": [ + slots[i].interval_start.isoformat() + for i in sorted(neg_evening_before_neg_ts) + ], + "neg_sell_prep_window_v36": bool(neg_sell_phases_en), "neg_sell_day_pv_usable_wh": ( _neg_sell_day_pv_usable_wh( slots, diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 4fcc444..c09c180 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -19,6 +19,7 @@ from services.planning_engine import ( _in_night_battery_export_window, _neg_sell_day_phases, _neg_sell_phases_enabled, + _pre_neg_pv_export_bundle, _pre_neg_buy_soc_ceiling_wh, _pre_neg_peak_sell_idx, _pre_neg_pv_export_forecast_cushion_ok, @@ -3771,6 +3772,18 @@ class NegSellSocPhaseTests(unittest.TestCase): self.assertGreater(float(meta.get("e_surplus_after_t_wh") or 0), 0.0) self.assertIn("post_detach_prep_ts", meta) + def test_t_detach_not_first_neg_on_long_sunny_day(self) -> None: + """Bod T až po nabití rampy (~85 % soc_max), ne na prvním sell<0 slotu.""" + slots = self._neg_sell_slots(24, pv_b=7000, pv_a=5000) + bat = self._phase_battery(tail_slots=4) + _ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat) + day = meta["days"][0] + self.assertGreater( + int(day["t_detach_idx"]), + int(day["first_neg_idx"]), + "t_detach must be after first neg slot on long window", + ) + def test_prep_reaches_soc_by_mid_window(self) -> None: slots = self._neg_sell_slots(12) bat = self._phase_battery() @@ -4019,5 +4032,67 @@ class PreNegPvExportForecastTests(unittest.TestCase): self.assertGreater(results[2].battery_setpoint_w, 2000) +class NegSellPrepWindowV36Tests(unittest.TestCase): + """v36: pre-neg per den, opravený bod T, večerní výboj před neg dnem.""" + + def test_pre_neg_bundle_second_calendar_day(self) -> None: + # Dva pražské dny: den 1 odpoledne neg, den 2 ráno před neg. + 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=7000, + pv_b_forecast_w=9000, + load_baseline_w=500, + ev1_connected=False, + ev2_connected=False, + allow_charge=True, + allow_discharge_export=True, + ) + ) + bat = _battery(uc_wh=64_000.0, max_pct=95.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) + export_ts, cushion = _pre_neg_pv_export_bundle( + slots, + bat, + 0.35 * bat.soc_max_wh, + None, + neg_sell_phases_en=True, + soc_target_by_t=tg, + ) + self.assertGreaterEqual(len(cushion), 2) + self.assertGreater(len(export_ts), 0) + if len(meta.get("days", [])) >= 2: + second_first = int(meta["days"][1]["first_neg_idx"]) + second_morning = [ + t + for t in export_ts + if t < second_first and float(slots[t].sell_price) >= 0.0 + ] + self.assertGreater( + len(second_morning), + 0, + "morning before 2nd neg day should allow pre-neg export", + ) + + if __name__ == "__main__": unittest.main() diff --git a/docs/04-modules/planning-neg-sell-strategy.md b/docs/04-modules/planning-neg-sell-strategy.md index 681e95a..8d7377a 100644 --- a/docs/04-modules/planning-neg-sell-strategy.md +++ b/docs/04-modules/planning-neg-sell-strategy.md @@ -130,7 +130,7 @@ Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](p ### 4.4 v35 — rampa SoC z PV B, bod T, přebytek ✅ -**Tag:** `2026-05-28-neg-sell-b-ramp-v35` +**Tag:** `2026-05-28-neg-sell-b-ramp-v35` (bod T opraven v **v36** — viz níže). **Kód:** `_neg_sell_pv_b_charge_wh`, `_neg_sell_day_phases` (rampa), `_neg_sell_e_surplus_after_t_wh`, `_neg_sell_day_pv_b_usable_wh` (cushion v33). @@ -141,6 +141,27 @@ Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](p **Ověření:** `NegSellSocPhaseTests::test_b_ramp_t_detach_and_surplus_meta`, MCP `solver_params`. +### 4.5 v36 — přípravné okno neg dne ✅ + +**Tag:** `2026-05-28-neg-prep-window-v36` + +| Problém v35 | Oprava v36 | +|-------------|------------| +| **T** hned na 1. `sell<0` → celý den curtail A | `t_detach` až `soc_need[t] ≥ 85 % soc_max` + suffix B ≥ zbytek do 100 % | +| Ráno 2. neg dne nabíjí místo exportu | **Pre-neg per den** + cushion **A+B**; `pre_neg_pv_export_slots` pro každý pražský den zvlášť | +| Večer nevybije před zítřejším neg | `neg_evening_before_neg_slots` — výboj večer **D−1** | + +**Cílová časová osa (např. 27. 5.):** + +```text +07–09:30 sell ≥ 0 → export FVE (pre-neg, cushion OK) +09:45+ sell < 0 → nabíjení A+B po rampě +~11–13 bod T → uvolnění / curtail A, B do domu nebo export +večer 26.5 → vybít bat před neg 27.5 (headroom) +``` + +**Ověření:** `NegSellPrepWindowV36Tests`, `solver_params.inputs.pre_neg_cushion_by_day`, `neg_evening_before_neg_slots`. + --- ## 5. Specifikace rampy (v35 — reference) @@ -185,18 +206,16 @@ soc_need[t-1] = soc_need[t] - charge_b[t] # clamp ≥ min_soc_wh Výsledkem je **`soc_need[t]`** — požadované SoC na **konci** slotu `t`, kdyby stačilo jen B. -### 5.3 Bod T (`t_detach`) +### 5.3 Bod T (`t_detach`) — v36 -**Definice:** nejmenší `t` v prep části, kde: +**Definice (implementováno v36):** první prep slot `t`, kde současně: ```text -soc_need[t] ≤ soc_detach_wh +soc_need[t] ≥ max(0,85 × soc_max, 0,92 × soc_need[tail_start]) +Σ charge_b[t..konec] ≥ (soc_max − soc_need[t]) × 1,05 ``` -kde `soc_detach_wh` může být: - -- konfigurovatelné % (náhrada za fixních 80 %), nebo -- odvozené z `soc_need` v okamžiku přechodu („natural“ detach). +**Zrušeno (chyba v35):** `soc_need[t] ≤ soc_need[tail_start]` — platilo vždy na začátku okna. **Interpretace:** diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 7388efb..cf09906 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -50,7 +50,7 @@ **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. 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`** (legacy): safety deficit cílí **`soc_max_wh`**; po posledním **`sell < 0`**: **`post_neg_pv_topup`**. **Planner tag v32:** fázované SoC — viz níže. -- **Záporný výkup — strategie home-01 (v32–v35 hotovo, v36+ návrh):** Kompletní specifikace (rampa SoC z PV B, bod **T**, termika, bazén, UI curtail): **[`planning-neg-sell-strategy.md`](planning-neg-sell-strategy.md)**. Stručně — **v32:** fáze prep/tail, curtail A. **v33:** export FVE před `sell<0` s forecast pojistkou (B usable). **v34:** tvrdý load-first. **v35:** rampa `soc_need` z PV B, **t_detach**, `E_surplus_after_t` (tag `2026-05-28-neg-sell-b-ramp-v35`). +- **Záporný výkup — strategie home-01 (v32–v36 prep hotovo):** **[`planning-neg-sell-strategy.md`](planning-neg-sell-strategy.md)**. **v35:** rampa B. **v36 prep:** oprava **T**, pre-neg per den (cushion A+B), večer D−1 (`2026-05-28-neg-prep-window-v36`). **v36 termika** (TČ/TUV) — otevřeno. - **Před sell<0 — export FVE s forecast pojistkou (v33):** `_pre_neg_pv_export_forecast_cushion_ok` — export FVE v kladných slotech před prvním `sell<0` jen pokud součet predikovaného PV přebytku v sell<0 okně (týž pražský den) pokryje dobítí na prep SoC (× **1,15**). Jinak LP raději nabíjí z FVE (riziko deště). Při splněné pojistce: `bc_pv=0` v `pre_neg_pv_export_ts`, shortfall na `ge_pv`, penalizace `bc_pv`. `solver_params.inputs.pre_neg_pv_export_forecast_ok`, `pre_neg_pv_export_slots`. Testy `PreNegPvExportForecastTests`. 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:** - horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu). diff --git a/docs/06-open-questions.md b/docs/06-open-questions.md index 45122b0..eee56e6 100644 --- a/docs/06-open-questions.md +++ b/docs/06-open-questions.md @@ -38,12 +38,15 @@ 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`). +- [ ] **v36 termika** — blok TČ v pre-neg exportu, TUV po **T**, doklep **19:00** (zatím jen plán). #### Roadmap (pořadí) 1. ~~**v35**~~ — hotovo -2. **Workshop UI** — flexibilní zátěže (viz výše) -3. **v36** — termika (blok TČ pre-neg, TUV v `sell < 0`, doklep **19:00**) +2. ~~**v36 prep okno**~~ — hotovo (T, pre-neg per den, večer D−1) +3. **Workshop UI** — flexibilní zátěže (viz výše) +4. **v36 termika** — TČ / TUV v `sell < 0` 4. **v37** — bazén (Shelly + LP), až po UI dohodě 5. **v38** — spirála (Loxone) diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index cfd4f99..9bcd2f9 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -11,6 +11,14 @@ 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) + +**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-prep-window-v36`. + +**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. + +**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. + ## 2026-05-28 — Rampa SoC z PV B, bod T (v35) **Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-sell-b-ramp-v35`.