From 58b0a2f882efba830a1e9f12b8e394ee346055e3 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Tue, 26 May 2026 13:28:31 +0200 Subject: [PATCH] implementace dynamickeho bodu T (kde se rodpojuje PV A) --- backend/services/planning_engine.py | 199 +++++++++++++++--- backend/tests/test_planning_dispatch_milp.py | 26 ++- docs/04-modules/planning-neg-sell-strategy.md | 111 +++++++--- docs/04-modules/planning.md | 2 +- docs/06-open-questions.md | 33 ++- docs/planning-changelog.md | 12 +- 6 files changed, 310 insertions(+), 73 deletions(-) diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 5a28259..4e02add 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,9 @@ 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-load-first-hard-v34" +PLANNER_BUILD_TAG = "2026-05-28-neg-sell-b-ramp-v35" +# 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 # 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 @@ -821,34 +823,112 @@ def _neg_sell_phases_enabled(battery: Any) -> bool: return prep_pct < 100.0 - 1e-6 and tail_slots > 0 +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)) + if pv_surplus_b <= 500.0: + return 0.0 + cap_w = min(pv_surplus_b, float(battery.max_charge_power_w)) + return cap_w * INTERVAL_H * float(battery.charge_efficiency) + + +def _neg_sell_day_pv_b_usable_wh( + slots: list[PlanningSlot], + first_neg_sell_idx: int | None, + battery: Any, +) -> float: + """Součet B-nabíjení ve všech sell<0 slotech téhož pražského dne.""" + if first_neg_sell_idx is None: + return 0.0 + neg_day = _prague_calendar_date(slots[first_neg_sell_idx]) + total = 0.0 + for s in slots: + if _prague_calendar_date(s) != neg_day: + continue + if float(s.sell_price) >= 0.0: + continue + total += _neg_sell_pv_b_charge_wh(s, battery) + return total + + +def _neg_sell_e_surplus_after_t_wh( + slots: list[PlanningSlot], + t_detach: int, + last_neg: int, + battery: Any, +) -> float: + """Integrál přebytku FVE nad load+bat cap od t_detach do last_neg (Wh).""" + total = 0.0 + for t in range(t_detach, last_neg + 1): + if t < 0 or t >= len(slots): + continue + st = slots[t] + if float(st.sell_price) >= 0.0: + continue + pv_surplus = max( + 0.0, + float(st.pv_a_forecast_w) + + float(st.pv_b_forecast_w) + - float(st.load_baseline_w), + ) + if pv_surplus <= 500.0: + continue + cap_charge_wh = ( + min(pv_surplus, float(battery.max_charge_power_w)) + * INTERVAL_H + * float(battery.charge_efficiency) + ) + total += max(0.0, pv_surplus * INTERVAL_H - cap_charge_wh) + return total + + def _neg_sell_day_phases( slots: list[PlanningSlot], battery: Any, -) -> tuple[list[str], list[Optional[float]], list[float]]: +) -> tuple[list[str], list[Optional[float]], list[float], dict[str, Any]]: """ - Per slot: phase (none|prep|tail), soc_target_wh (None mimo sell<0 fáze), prep shortfall váha. - Fáze po kalendářním dni v Europe/Prague. + 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]. """ t_len = len(slots) phases: list[str] = ["none"] * t_len soc_targets: list[Optional[float]] = [None] * t_len shortfall_weights: list[float] = [0.0] * t_len - prep_pct = float(getattr(battery, "planner_neg_sell_prep_soc_percent", 100.0)) tail_n = int(getattr(battery, "planner_neg_sell_full_soc_tail_slots", 0)) - prep_wh = prep_pct / 100.0 * float(battery.soc_max_wh) soc_max = float(battery.soc_max_wh) + min_soc = float(battery.min_soc_wh) + post_detach_prep_ts: set[int] = set() + day_meta: list[dict[str, Any]] = [] 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, indices in by_day.items(): + for day, indices in by_day.items(): if not indices: continue indices.sort() last_t = indices[-1] - tail_start = max(indices[0], last_t - tail_n + 1) + tail_start = max(indices[0], last_t - tail_n + 1) if tail_n > 0 else last_t + 1 + charge_b = {t: _neg_sell_pv_b_charge_wh(slots[t], battery) for t in indices} + soc_need: dict[int, float] = {last_t: soc_max} + for i in range(len(indices) - 1, 0, -1): + t_cur = indices[i] + 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 + + e_surplus = _neg_sell_e_surplus_after_t_wh(slots, t_detach, last_t, battery) + for t in indices: if t >= tail_start: phases[t] = "tail" @@ -857,12 +937,45 @@ def _neg_sell_day_phases( else: pos = t - tail_start frac = pos / float(max(1, tail_n - 1)) - soc_targets[t] = prep_wh + frac * (soc_max - prep_wh) + lo = float(soc_need.get(tail_start, soc_max)) + soc_targets[t] = lo + frac * (soc_max - lo) else: phases[t] = "prep" - soc_targets[t] = prep_wh + soc_targets[t] = float(soc_need[t]) + if t >= t_detach: + post_detach_prep_ts.add(t) shortfall_weights[t] = float(last_t - t + 1) / float(len(indices)) - return phases, soc_targets, shortfall_weights + + day_meta.append( + { + "prague_date": str(day), + "first_neg_idx": indices[0], + "last_neg_idx": last_t, + "tail_start_idx": tail_start, + "t_detach_idx": t_detach, + "soc_detach_wh": soc_detach_wh, + "e_surplus_after_t_wh": e_surplus, + "soc_ramp_wh": [ + { + "slot": slots[t].interval_start.isoformat(), + "soc_need_wh": float(soc_need[t]), + "phase": phases[t], + "soc_target_wh": float(soc_targets[t] or 0.0), + } + for t in indices + ], + } + ) + + meta: dict[str, Any] = { + "neg_sell_b_ramp_v35": True, + "days": day_meta, + "post_detach_prep_ts": sorted(post_detach_prep_ts), + } + if day_meta: + meta["t_detach_idx"] = day_meta[0]["t_detach_idx"] + meta["e_surplus_after_t_wh"] = day_meta[0]["e_surplus_after_t_wh"] + return phases, soc_targets, shortfall_weights, meta def _neg_sell_day_pv_usable_wh( @@ -906,25 +1019,27 @@ def _pre_neg_pv_export_forecast_cushion_ok( neg_sell_phases_en: bool, ) -> bool: """ - Export FVE před sell<0 jen pokud forecast v záporném okně pokryje dobítí na cíl (typ. 80 %). + 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. """ if first_neg_sell_idx is None or first_neg_sell_idx <= 0: return False - prep_pct = float(getattr(battery, "planner_neg_sell_prep_soc_percent", 100.0)) - if neg_sell_phases_en and prep_pct < 100.0 - 1e-6: - target_wh = prep_pct / 100.0 * float(battery.soc_max_wh) + if neg_sell_phases_en: + _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( + slots, + first_neg_sell_idx, + max_charge_power_w=float(battery.max_charge_power_w), + charge_efficiency=float(battery.charge_efficiency), + ) needed_wh = max(0.0, target_wh - float(current_soc_wh)) if needed_wh < PRE_NEG_PV_EXPORT_MIN_NEEDED_WH: return True - usable_wh = _neg_sell_day_pv_usable_wh( - slots, - first_neg_sell_idx, - max_charge_power_w=float(battery.max_charge_power_w), - charge_efficiency=float(battery.charge_efficiency), - ) return usable_wh >= needed_wh * PRE_NEG_PV_EXPORT_FORECAST_MARGIN @@ -1725,12 +1840,18 @@ def solve_dispatch( neg_sell_phase_by_t: list[str] = ["none"] * T neg_sell_soc_target_by_t: list[Optional[float]] = [None] * T neg_sell_shortfall_weight_by_t: list[float] = [0.0] * T + neg_sell_day_meta: dict[str, Any] = {} + neg_sell_post_detach_prep_ts: set[int] = set() if neg_sell_phases_en: ( neg_sell_phase_by_t, neg_sell_soc_target_by_t, neg_sell_shortfall_weight_by_t, + neg_sell_day_meta, ) = _neg_sell_day_phases(slots, battery) + neg_sell_post_detach_prep_ts = set( + neg_sell_day_meta.get("post_detach_prep_ts") or [] + ) prep_soc_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] prep_hold_bcpv_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] prep_hold_curtail_shortfall: list[tuple[int, pulp.LpVariable, float]] = [] @@ -2265,6 +2386,13 @@ def solve_dispatch( / 1000.0 for t in pre_neg_pv_export_ts ) + + pulp.lpSum( + bc_pv[t] + * NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH + * INTERVAL_H + / 1000.0 + for t in neg_sell_post_detach_prep_ts + ) + pulp.lpSum( sf * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH * INTERVAL_H / 1000.0 for _t, sf, _cap in neg_sell_bat_dump_shortfall @@ -2329,18 +2457,13 @@ def solve_dispatch( prob += us >= float(tgt_prep) - soc[t_us] for t_us, us, tgt_wh in neg_sell_soc_underfill: prob += us >= float(tgt_wh) - soc[t_us] - prep_wh_phases = ( - float(getattr(battery, "planner_neg_sell_prep_soc_percent", 80.0)) - / 100.0 - * float(battery.soc_max_wh) - if neg_sell_phases_en - else 0.0 - ) m_hold_soc = float(battery.soc_max_wh) for t_h, sf_h, cap_h in prep_hold_bcpv_shortfall: w_h = prep_hold_met_binary[t_h] soc_prev_h = current_soc_wh if t_h == 0 else soc[t_h - 1] - prob += soc_prev_h >= prep_wh_phases - m_hold_soc * (1 - w_h) + tgt_hold = neg_sell_soc_target_by_t[t_h] + hold_thr = float(tgt_hold) if tgt_hold is not None else float(battery.soc_max_wh) + prob += soc_prev_h >= hold_thr - m_hold_soc * (1 - w_h) prob += sf_h >= bc_pv[t_h] - cap_h * w_h for t_c, sf_c, cap_c in prep_hold_curtail_shortfall: w_c = prep_hold_met_binary[t_c] @@ -3189,6 +3312,9 @@ def solve_dispatch( if neg_sell_soc_target_by_t[t] is not None else None ), + "neg_sell_post_detach_prep": ( + t in neg_sell_post_detach_prep_ts if neg_sell_phases_en else None + ), } ) tgt_s = st.safety_soc_target_wh if daytime_en else None @@ -3285,6 +3411,21 @@ def solve_dispatch( ), }, "neg_sell_phases_enabled": bool(neg_sell_phases_en), + "neg_sell_b_ramp_v35": bool(neg_sell_phases_en), + "neg_sell_day_meta": neg_sell_day_meta if neg_sell_phases_en else None, + "t_detach_idx": ( + neg_sell_day_meta.get("t_detach_idx") if neg_sell_phases_en else None + ), + "e_surplus_after_t_wh": ( + neg_sell_day_meta.get("e_surplus_after_t_wh") + if neg_sell_phases_en + else None + ), + "neg_sell_day_pv_b_usable_wh": ( + _neg_sell_day_pv_b_usable_wh(slots, first_neg_sell_idx, battery) + if first_neg_sell_idx is not None and neg_sell_phases_en + else None + ), "pre_neg_pv_export_forecast_ok": bool(pre_neg_pv_export_forecast_ok), "pre_neg_pv_export_slots": [ slots[i].interval_start.isoformat() for i in sorted(pre_neg_pv_export_ts) diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 42f41e8..4fcc444 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -3703,7 +3703,7 @@ class PlannerArbitrageImprovementsTests(unittest.TestCase): class NegSellSocPhaseTests(unittest.TestCase): - """Fázované SoC v okně sell<0 (v32): prep 80 %, tail rampa, vent B s prahem.""" + """Fázované SoC v okně sell<0 (v35): rampa z PV B, tail, vent B s prahem.""" @staticmethod def _phase_battery(**kw: float) -> SimpleNamespace: @@ -3750,11 +3750,26 @@ class NegSellSocPhaseTests(unittest.TestCase): def test_day_phases_tail_last_four(self) -> None: slots = self._neg_sell_slots(10) bat = self._phase_battery(tail_slots=4) - phases, targets, _w = _neg_sell_day_phases(slots, bat) + phases, targets, _w, meta = _neg_sell_day_phases(slots, bat) self.assertEqual(phases[5], "prep") self.assertEqual(phases[9], "tail") self.assertEqual(phases.count("tail"), 4) self.assertAlmostEqual(float(targets[9] or 0), bat.soc_max_wh, delta=50.0) + self.assertTrue(meta.get("neg_sell_b_ramp_v35")) + prep_targets = [float(targets[t] or 0) for t in range(6) if phases[t] == "prep"] + self.assertGreater(len(prep_targets), 1) + for a, b in zip(prep_targets, prep_targets[1:]): + self.assertGreaterEqual(b, a - 1.0) + + def test_b_ramp_t_detach_and_surplus_meta(self) -> None: + slots = self._neg_sell_slots(12, pv_b=6000) + bat = self._phase_battery(tail_slots=4) + _ph, _tg, _w, meta = _neg_sell_day_phases(slots, bat) + self.assertIsNotNone(meta.get("t_detach_idx")) + self.assertGreaterEqual(int(meta["t_detach_idx"]), 0) + self.assertLess(int(meta["t_detach_idx"]), 8) + self.assertGreater(float(meta.get("e_surplus_after_t_wh") or 0), 0.0) + self.assertIn("post_detach_prep_ts", meta) def test_prep_reaches_soc_by_mid_window(self) -> None: slots = self._neg_sell_slots(12) @@ -3782,6 +3797,8 @@ class NegSellSocPhaseTests(unittest.TestCase): ) self.assertEqual(snap.get("planner_build_tag"), PLANNER_BUILD_TAG) self.assertTrue(snap.get("inputs", {}).get("neg_sell_phases_enabled")) + self.assertTrue(snap.get("inputs", {}).get("neg_sell_b_ramp_v35")) + self.assertIsNotNone(snap.get("inputs", {}).get("t_detach_idx")) # Nabíjení z FVE v sell<0: SoC roste, tail má vyšší cíl než začátek okna. self.assertGreater(results[-1].battery_soc_target, results[0].battery_soc_target) self.assertGreaterEqual(results[-1].battery_soc_target, 75.0) @@ -3875,7 +3892,7 @@ class NegSellSocPhaseTests(unittest.TestCase): class PreNegPvExportForecastTests(unittest.TestCase): - """v33: export FVE před sell<0 jen pokud forecast v sell<0 okně pokryje prep SoC.""" + """v33/v35: export FVE před sell<0 jen pokud forecast B v sell<0 okně pokryje soc_need z rampy.""" @staticmethod def _slots_morning_then_neg(n: int = 22, *, neg_pv_scale: float = 1.0) -> list[PlanningSlot]: @@ -3885,7 +3902,8 @@ class PreNegPvExportForecastTests(unittest.TestCase): sell = -0.25 if i >= 6 else (2.8 if i < 4 else 1.2) if i >= 6: pv_a = (8000 + (i - 6) * 500) * neg_pv_scale - pv_b = 6000.0 * neg_pv_scale + # v35 cushion: usable jen z B — dostatečný B pro rampu v test_cushion_ok + pv_b = 9500.0 * neg_pv_scale else: pv_a = 1500 + i * 400 pv_b = 1500.0 diff --git a/docs/04-modules/planning-neg-sell-strategy.md b/docs/04-modules/planning-neg-sell-strategy.md index b0a3159..681e95a 100644 --- a/docs/04-modules/planning-neg-sell-strategy.md +++ b/docs/04-modules/planning-neg-sell-strategy.md @@ -2,7 +2,7 @@ Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md), [`planning-changelog.md`](../planning-changelog.md), [`heat-pump.md`](heat-pump.md), [`ev-charging.md`](ev-charging.md). -**Stav:** část je **implementovaná** (v32–v34), část je **návrh** (v35+ termika, bazén, spirála). V textu je označeno `✅ hotovo` vs `📋 návrh`. +**Stav:** část je **implementovaná** (v32–v35), část je **návrh** (v36+ termika, bazén, spirála). V textu je označeno `✅ hotovo` vs `📋 návrh`. --- @@ -31,6 +31,7 @@ Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](p | **`E_surplus_after_t`** 📋 | Integrál plánovaného přebytku FVE (typ. od **T** do `last_sell<0`), který by jinak šel do sítě / curtail — budget pro TČ předehřát, bazén, spirálu. | | **Pre-neg export (v33)** | Kladné `sell` **před** prvním `sell < 0`: export FVE jen pokud forecast v celém `sell < 0` okně pokryje dobítí na prep cíl (× margin **1,15**). | | **Load-first (v34)** | Dům z `pv_ld`; při dostatečné FVE žádný fiktivní `grid_import = load` v plánu. | +| **Rampa B + bod T (v35)** | `soc_need` zpět od tail jen z PV B; **t_detach**; `E_surplus_after_t`; uvolnění A po T (měkké). | | **Reg 340** | Deye *max solar power* ≈ `pv_a_forecast_solver_w − pv_a_curtailed_w`. | --- @@ -56,13 +57,13 @@ Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](p ### 3.2 Fáze B — okno `sell < 0` -**Energie (dnes v32, návrh v35):** +**Energie (v32–v35):** -| Období v B | Dnes (v32) | Návrh (v35) | -|------------|------------|-------------| -| Začátek okna | ASAP nabít na **80 %** z A+B | Nabít podle **rampy SoC** odvozené zpět z B od tail | -| Střed okna | Měkký **curtail A** při SoC ≥ 80 % na začátku slotu | Od **T**: A necpát do bat; B + přebytek | -| Tail (posledních N slotů) | Rampa 80 % → 100 % | Rampa z **T** / B → 100 % | +| Období v B | Chování (v35) | +|------------|----------------| +| Začátek okna | Nabít podle **rampy SoC** (`soc_need`) zpět z PV B od tail | +| Střed okna | Od **t_detach**: měkké omezení `bc_pv`; hold/curtail při `soc_prev ≥ soc_target[t]` | +| Tail (posledních N slotů) | Rampa z `soc_need[tail_start]` → 100 % | **Termika (📋):** @@ -83,7 +84,7 @@ Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](p --- -## 4. Implementované vrstvy (v32–v34) +## 4. Implementované vrstvy (v32–v35) ### 4.1 v32 — fázované SoC a curtail A ✅ @@ -91,7 +92,7 @@ Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](p | Sloupec | Default | Význam | |---------|---------|--------| -| `planner_neg_sell_prep_soc_percent` | 80 | Plochý cíl SoC v prep fázi (% `soc_max`). **100** = legacy (tlak na max až v tail). | +| `planner_neg_sell_prep_soc_percent` | 80 | **v32 legacy** — od v35 se v LP neřídí (rampa z B). **100** = vypnutí fází (`_neg_sell_phases_enabled`). | | `planner_neg_sell_full_soc_tail_slots` | 4 | Počet 15min slotů tail před koncem denního `sell < 0`. **0** = bez tail. | | `planner_neg_sell_vent_min_sell_czk_kwh` | −1 (home-01) | V tail: ventil pole B (`ge_pv`) pokud `sell ≥` práh. **NULL** = jen při plné baterii. | @@ -127,11 +128,32 @@ Navazuje na [`planning.md`](planning.md), [`planning-arbitrage-accounting.md`](p **Ověření:** `LoadFirstDispatchTests::test_neg_sell_prep_no_fictitious_grid_import_for_load`. +### 4.4 v35 — rampa SoC z PV B, bod T, přebytek ✅ + +**Tag:** `2026-05-28-neg-sell-b-ramp-v35` + +**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). + +- Zpětná projekce `soc_need` jen z PV B; prep `soc_target[t] = soc_need[t]` (ne fixních 80 %). +- **t_detach** = první prep slot kde `soc_need[t] ≤ soc_need[tail_start]`; **E_surplus_after_t** od T do konce okna. +- Prep hold: `soc_prev ≥ soc_target[t]`; po T: `NEG_SELL_POST_DETACH_BCPV_DISCOURAGE` na `bc_pv`. +- `solver_params.inputs`: `neg_sell_b_ramp_v35`, `t_detach_idx`, `e_surplus_after_t_wh`, `neg_sell_day_meta`. + +**Ověření:** `NegSellSocPhaseTests::test_b_ramp_t_detach_and_surplus_meta`, MCP `solver_params`. + --- -## 5. Návrh v35 — energie: rampa z PV B, bod T, přebytek +## 5. Specifikace rampy (v35 — reference) -📋 **Není v produkci** — specifikace pro implementaci. +### 5.0 Rozhodnutí produktu (home-01, 2026-05) + +| Téma | Rozhodnutí | +|------|------------| +| Rampa / **T** | Odvozené z PV B; **bez** řízení fixním `planner_neg_sell_prep_soc_percent` v LP pro home-01. | +| TČ v pre-neg | **Zákaz** plánovaného topení. | +| Bazén | Min. 4 h filtrace/den, dynamicky navýšit; Shelly; přitop ručně / později. | +| Spirála | Loxone; v38. | +| UI flex | Workshop **před** v37 — viz § 9.1. | ### 5.1 Kotva vzadu (tail — beze změny konceptu) @@ -270,25 +292,28 @@ Navrhované klíče v `planning_run.solver_params.inputs`: Spirála vyžaduje **novou zátěž** v DB + LP (`flex_load_spiral[t]` nebo signál Loxone). -### 6.4 Konfigurovatelné teploty (📋 — rozhodnutí) +### 6.4 Parametry termiky (rozhodnutí + otevřeno) -| Parametr | Navrh | Poznámka | -|----------|-------|----------| -| `tuv_comfort_temp_c` | např. 50–52 | Denní komfort | -| `tuv_preheat_temp_c` | např. 55–58 | V bodu **T**, podmíněně | -| `tuv_evening_topup_before_min` | např. 90 | Doklep před sprchou | -| `hp_no_run_pre_neg_export` | true | Blok TČ ve fázích A (v33 sloty) | +| Parametr | Stav | Hodnota / poznámka | +|----------|------|---------------------| +| `hp_no_run_pre_neg_export` | **Rozhodnuto** | `true` — v `pre_neg_pv_export_ts` **netopit** (raději export FVE). | +| `tuv_comfort_temp_c` | Otevřeno | Např. 50–52 °C — doplnit do konfigurace site. | +| `tuv_preheat_temp_c` | Otevřeno | Např. 55–58 °C — jen v bodu **T**, pokud `E_surplus_after_t` stačí. | +| `tuv_evening_topup_hour` | **Rozhodnuto** | **19:00** Europe/Prague — večerní doklep TUV (implementace v36). | +| Spirála | **Rozhodnuto** | Ovládání **Loxone**; model v EMS až v38. | --- ## 7. Bazén — filtrace a přitop (📋) -### 7.1 Provozní záměr +### 7.1 Provozní záměr (rozhodnutí home-01) -- **Filtrace ~1 kW** — regulovatelný **denní rozpočet hodin** (např. 4–6 h). -- **Kdy:** jen ve **slunečných** hodinách (např. 09:00–17:00 Prague, nebo příznak `is_daytime_pv_surplus_slot` z `fn_load_planning_slots_full`). -- **Proč ve dni:** cirkulace promíchá prohřátou hladinu (uživatelský požadavek). -- **Priorita:** po naplnění bat rampy / od **T**, před exportem B za `sell < 0`. +- **Filtrace ~1 kW** — min. **4 h/den**; **více hodin**, pokud `E_surplus_after_t` a přebytek dovolí (marginalní náklad ≈ 0). +- **Kdy:** přes den ve **slunečných** slotech (`is_daytime_pv_surplus_slot` nebo obdobné); **dynamicky** dle cen / přebytku, ne pevné okno 09–17. +- **Proč ve dni:** cirkulace promíchá prohřátou hladinu. +- **Priorita:** po rampě bat / od bodu **T**, před exportem B za `sell < 0`. +- **Přitop vody:** **mimo** první verzi plánovače; začátek sezóny **ručně**; automatika později. +- **Exekuce:** **Shelly** — ovládání z EMS po implementaci assetu (v37). ### 7.2 Napojení na `E_surplus_after_t` @@ -342,6 +367,27 @@ Příklad: forecast A = 4 654 W, curtail = 1 117 W → povoleno **3 537 W* **Bat. / síť / SoC:** `battery_setpoint_w` / `grid_setpoint_w` / `battery_soc_target_pct` — po v34 u vysoké FVE **grid ≈ 0**, ne fiktivní import = load. +### 9.1 Vizualizace flexibilních zátěží — probrat před implementací (📋) + +**Stav:** produktové rozhodnutí **není** — **neimplementovat** bazén / rozšířené TČ v UI ani v LP sinku, dokud není schválený návrh. Workshop mezi **v35** a **v37**. + +**Proč:** flexibilní zátěže (TČ, bazén, spirála, EV) sdílí stejnou časovou osu jako energie (**T**, `E_surplus_after_t`, fáze sell<0). Bez přehledného UI bude provoz těžko kontrolovatelný. + +**Návrhy k diskusi** (nic z toho není závazná implementace): + +| Nápad | Co ukázat | +|-------|-----------| +| **Pásma dne** | V grafu plánu: pre-neg export \| sell<0 prep \| od **T** \| tail \| večerní export bat. | +| **Bod T** | Svislá značka + tooltip: `t_detach`, `e_surplus_after_t_wh`, odhad hodin bazénu. | +| **Rozpočet bazénu** | „Dnes 2/4 h filtrace naplánováno“ + zbývající Wh přebytku. | +| **Slot detail** | Kromě bat/síť/FVE: **TČ** (`heat_pump_setpoint_w`), **EV**, (budoucí) **bazén ON**, badge **flex sink**. | +| **Srovnání běhů** | Před/po v35: rampa SoC, méně fiktivního grid importu, curtail A. | +| **Živě vs plán** | Volitelně: telemetrie TUV / Shelly pool vs plánovaný stav (až bude data). | + +**Výstup workshopu:** krátký mock / seznam widgetů v `Planning.tsx` + které sloupce ukládat do `planning_interval` / `solver_params`. + +**Otevřené otázky UI:** viz [`docs/06-open-questions.md`](../06-open-questions.md). + --- ## 10. Priorita flexibilních spotřebičů (📋) @@ -363,14 +409,15 @@ Při `sell < 0` a plné / dostatečné baterii: ## 11. Roadmap implementace -| Fáze | Tag / doc | Obsah | Závislost | -|------|-----------|--------|-----------| -| **v35** | `neg-sell-b-ramp-v35` | Rampa `soc_need` z B, **T**, `E_surplus_after_t`, uvolnění A | V083 sloupce; náhrada plochých 80 % v LP | -| **v36** | termika-v36 | Blok TČ v pre-neg; TUV doklep; komfort v `sell<0` po **T** | v35 | -| **v37** | pool-v37 | Asset bazén, denní hodiny, LP sink | v35 | -| **v38** | spiral-v38 | Spirála + volba TČ vs spirála | Loxone/Modbus, v37 | +| Pořadí | Fáze | Tag / doc | Obsah | Blokátor | +|--------|------|-----------|--------|----------| +| 1 | **v35** ✅ | `neg-sell-b-ramp-v35` | Rampa `soc_need` z B, **T**, `E_surplus_after_t`, uvolnění A | — | +| 2 | **UI workshop** | — | Vizualizace flex. zátěží — § 9.1; schválený návrh widgetů | **Před v37** | +| 3 | **v36** | `termika-v36` | Blok TČ pre-neg; TUV v `sell<0` po **T**; večerní doklep **19:00** Prague | v35 | +| 4 | **v37** | `pool-v37` | Bazén: Shelly, min 4 h/den, LP sink | UI workshop | +| 5 | **v38** | `spiral-v38` | Spirála (Loxone) + volba TČ vs spirála | v37 | -Každá fáze: migrace (pokud DB), `planning_engine.py`, testy MILP, zápis do `planning-changelog.md`, ověření na home-01 přes MCP. +Každá implementační fáze: migrace (pokud DB), `planning_engine.py`, testy MILP, `planning-changelog.md`, ověření MCP na home-01. --- @@ -426,4 +473,6 @@ cd backend && python3 -m pytest tests/test_planning_dispatch_milp.py -k "NegSell ## 14. Otevřená rozhodnutí -Přesunuta do [`docs/06-open-questions.md`](../06-open-questions.md) sekce **Plánování — neg sell, termika, bazén** — nutné doplnit před v36+. +Živý seznam: [`docs/06-open-questions.md`](../06-open-questions.md) — sekce **Plánování — neg sell, termika, flexibilní zátěže**. + +Zbývá hlavně: **čas večerního doklepu TUV** (~19h?), **návrh UI flex zátěží** (workshop před v37). diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index e1f19da..7388efb 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–v34 hotovo, v35+ 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:** `planner_neg_sell_prep_soc_percent` (80 %), `planner_neg_sell_full_soc_tail_slots` (4), `planner_neg_sell_vent_min_sell_czk_kwh`; fáze prep/tail, měkký curtail A. **v33:** export FVE před `sell<0` s forecast pojistkou. **v34:** tvrdý load-first. **v35 (návrh):** nahradit fixních 80 % rampou z B a bodem **T**. +- **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`). - **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 de8fce1..45122b0 100644 --- a/docs/06-open-questions.md +++ b/docs/06-open-questions.md @@ -18,15 +18,34 @@ Tento soubor slouží jako živý seznam věcí které je potřeba rozhodnout p ## Důležité (neblokují, ale řeší se brzy) -### Plánování — neg sell, termika, bazén +### Plánování — neg sell, termika, flexibilní zátěže -Kompletní návrh: [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/planning-neg-sell-strategy.md). Implementace v35+ čeká na doplnění: +Kompletní návrh: [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/planning-neg-sell-strategy.md). -- [ ] **v35 — bod T a rampa SoC z PV B** — potvrdit, zda `soc_detach_wh` = odvozené z rampy, nebo ponechat konfigurovatelné % jako strop (náhrada 80 %). -- [ ] **TUV teploty** — `tuv_comfort_temp_c`, `tuv_preheat_temp_c` pro předehřát v bodu T; čas večerního doklepu před sprchou (fixní hodina vs. uživatelský profil). -- [ ] **Bazén** — `filter_hours_per_day` (kolik hodin filtrace), okno slunce (Prague 09–17?), jen filtrace 1 kW nebo i přitop TČ vody. -- [ ] **Spirála** — je ovladatelná z EMS/Loxone? Samostatný asset vs. signál; priorita oproti TČ v dnech bez `sell < 0`. -- [ ] **TČ v pre-neg exportu** — potvrdit zákaz plánovaného topení ve slotech `pre_neg_pv_export_ts` (v36). +#### Rozhodnuto (home-01, 2026-05) + +| Téma | Rozhodnutí | +|------|------------| +| **v35 — bod T, rampa SoC** | `soc_detach` a rampa **jen odvozené** z forecastu PV B zpět od tail (100 %). Fixní **80 %** v LP pro home-01 **zrušit** (sloupce V083 mohou zůstat pro legacy/KV1, ale solver home-01 je neřídí). | +| **TČ před `sell < 0`** | V ranních slotech **pre-neg export** (v33) **netopit** — energii raději **prodat** do site. | +| **Spirála** | Ovládání přes **Loxone** (signál / virtuální vstup). Samostatný model v EMS až ve fázi v38. | +| **Bazén — filtrace** | Min. **4 h/den**, za dne **více**, pokud je přebytek (`E_surplus_after_t`) a „nic to nestojí“. Rozložení **dynamicky** dle cen / přebytku / slunce, ne pevné 09–17. | +| **Bazén — přitop** | **Mimo** automatiku plánovače na začátku; sezónní nahřátí **ručně**. Automatický přitop až později, pokud vůbec. | +| **Bazén — exekuce** | **Shelly** (zapínání filtrace) — napojit až po v37 (asset + LP), ovládání z EMS. | + +#### Otevřeno před implementací + +- [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`). + +#### 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**) +4. **v37** — bazén (Shelly + LP), až po UI dohodě +5. **v38** — spirála (Loxone) - [x] **Arbitráž baterie — 1. vlna (před solve):** `charge_acquisition_buy_czk_kwh` + cutoff před 1. `allow_discharge_export`; LP `+ge_bat×acquisition` v exportních slotech. Zbývá iterace po solve a více charge slotů — [`planning-arbitrage-accounting.md`](04-modules/planning-arbitrage-accounting.md) §6, [`docs/05-todo.md`](05-todo.md). diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index a791d0f..cfd4f99 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -7,7 +7,17 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen ## 2026-05-28 — Dokumentace strategie sell<0 + termika + bazén -**Soubor:** [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/planning-neg-sell-strategy.md) — cíle, slovník, časová osa dne, v32–v34 vs návrh v35+, TČ/TUV podle typu dne, bazén, UI curtail/reg 340, roadmap, SQL ověření. Otevřená rozhodnutí: [`docs/06-open-questions.md`](06-open-questions.md). +**Soubor:** [`docs/04-modules/planning-neg-sell-strategy.md`](04-modules/planning-neg-sell-strategy.md) — cíle, slovník, časová osa dne, v32–v35, návrh v36+, TČ/TUV podle typu dne, bazén, UI curtail/reg 340, roadmap, SQL 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 — Rampa SoC z PV B, bod T (v35) + +**Kód:** `backend/services/planning_engine.py` — tag `2026-05-28-neg-sell-b-ramp-v35`. + +**Změna:** `_neg_sell_day_phases` počítá `soc_need[t]` zpětnou projekcí jen z PV B; prep cíle = rampa (ne fixních 80 %). **t_detach**, **E_surplus_after_t** v `solver_params.inputs`. Prep hold na `soc_target[t]` z rampy; po T měkké `NEG_SELL_POST_DETACH_BCPV_DISCOURAGE`. Cushion v33: cíl z rampy, usable jen z B. + +**Ověření:** `pytest tests/test_planning_dispatch_milp.py -k "NegSell or PreNeg or LoadFirst"`; MCP `solver_params.inputs.neg_sell_day_meta`. ## 2026-05-28 — Tvrdý load-first v LP (v34)