diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index fac6ab6..f602aca 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -64,7 +64,7 @@ NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0 # Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž). EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12 NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0 -PLANNER_BUILD_TAG = "2026-05-26-neg-sell-bat-dump-extreme-buy-v11" +PLANNER_BUILD_TAG = "2026-05-27-self-consistent-grid-mask-v12" CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru @@ -357,6 +357,12 @@ class PlanningSlot: #: Vážená nákupní / opportunity cena zásoby před prvním exportním oknem (SQL odhad z masek). charge_acquisition_buy_czk_kwh: float | None = None charge_acquisition_cutoff_at: datetime | None = None + min_buy_before_cutoff_czk_kwh: float | None = None + pv_charge_wh_ahead: float | None = None + neg_buy_wh_ahead: float | None = None + grid_charge_suppressed_reason: str | None = None + #: Pomocny atribut pro green_bonus v planning_interval (Kc/slot); lite default 0. + green_bonus_czk_per_slot: float = 0.0 # Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu). @@ -510,6 +516,10 @@ class DispatchResult: effective_buy_price: float effective_sell_price: float is_predicted_price: bool # shodné s PlanningSlot (chybí OTE v efektivní ceně → fn_get_predicted_price) + cashflow_czk: float + battery_arbitrage_czk: float + penalty_czk: float + green_bonus_czk: float # ============================================================ @@ -997,6 +1007,7 @@ def solve_dispatch_two_pass( snap1["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6) snap1["inputs"]["two_pass_enabled"] = True snap1["inputs"]["two_pass_converged"] = True + snap1["inputs"]["two_pass_skipped"] = False return results1, ms1, snap1 slots2 = _slots_with_charge_acquisition(slots, acq2) @@ -1019,6 +1030,7 @@ def solve_dispatch_two_pass( snap2["inputs"]["acquisition_pass2_czk_kwh"] = round(acq2, 6) snap2["inputs"]["two_pass_enabled"] = True snap2["inputs"]["two_pass_converged"] = False + snap2["inputs"]["two_pass_skipped"] = False snap2["inputs"]["solver_duration_ms_pass1"] = ms1 return results2, ms1 + ms2, snap2 @@ -2128,10 +2140,59 @@ def solve_dispatch( if z_gen_cutoff is not None: deye_gen_cutoff = bool(round(float(pulp.value(z_gen_cutoff[t]) or 0))) - cost = ( + cashflow_czk_t = ( pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000 - pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000 ) + ge_bat_value = float(pulp.value(ge_bat[t]) or 0) + battery_arbitrage_czk_t = ( + ge_bat_value + * (float(slots[t].sell_price) - float(charge_acquisition_czk_kwh)) + * INTERVAL_H + / 1000.0 + ) + penalty_terms_t = 0.0 + for _tt, _sf, _cap in peak_export_shortfall: + if _tt == t: + penalty_terms_t += ( + float(pulp.value(_sf) or 0.0) + * PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH + * INTERVAL_H + / 1000.0 + ) + for _tt, _sf, _cap in pv_charge_shortfall: + if _tt == t: + penalty_terms_t += ( + float(pulp.value(_sf) or 0.0) + * PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH + * INTERVAL_H + / 1000.0 + ) + for _tt, _sf, _cap in neg_sell_bat_dump_shortfall: + if _tt == t: + penalty_terms_t += ( + float(pulp.value(_sf) or 0.0) + * NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH + * INTERVAL_H + / 1000.0 + ) + for _tt, _us in neg_sell_soc_underfill: + if _tt == t: + penalty_terms_t += ( + float(pulp.value(_us) or 0.0) + * NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH + ) + sv_t = safety_vars[t] + if sv_t is not None: + penalty_terms_t += float(pulp.value(sv_t) or 0.0) * safety_pen_czk_per_wh[t] + for _tt, _cv, _prev in commit_lp: + if _tt == t: + penalty_terms_t += float(pulp.value(_cv) or 0.0) * INTERVAL_H / 1000.0 * commit_pen + penalty_terms_t += float(pulp.value(ca[t]) or 0.0) * CURTAILMENT_PENALTY + green_bonus_czk_t = float( + getattr(slots[t], "green_bonus_czk_per_slot", 0.0) or 0.0 + ) + cost = cashflow_czk_t results.append(DispatchResult( interval_start = slots[t].interval_start, @@ -2155,6 +2216,10 @@ def solve_dispatch( effective_buy_price = slots[t].buy_price, effective_sell_price = slots[t].sell_price, is_predicted_price = bool(slots[t].is_predicted_price), + cashflow_czk = round(cashflow_czk_t, 4), + battery_arbitrage_czk = round(battery_arbitrage_czk_t, 4), + penalty_czk = round(penalty_terms_t, 4), + green_bonus_czk = round(green_bonus_czk_t, 4), )) sell_rank = sorted(range(T), key=lambda i: float(slots[i].sell_price), reverse=True)[: min(3, T)] @@ -2230,6 +2295,18 @@ def solve_dispatch( "safety_deficit_wh": sdv, "commitment_shortfall_w": cshort, "commitment_penalty_czk_kwh": float(commit_pen) if cshort is not None else None, + "acquisition_used_czk_kwh": float(charge_acquisition_czk_kwh), + "grid_charge_suppressed_reason": getattr( + st, "grid_charge_suppressed_reason", None + ), + "pv_charge_wh_ahead": float( + getattr(st, "pv_charge_wh_ahead", 0.0) or 0.0 + ), + "min_buy_before_cutoff_czk_kwh": ( + float(st.min_buy_before_cutoff_czk_kwh) + if getattr(st, "min_buy_before_cutoff_czk_kwh", None) is not None + else None + ), } ) night0 = slots[0] @@ -2844,7 +2921,9 @@ async def _load_slots( night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh, future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh, is_daytime_pv_surplus_slot, - charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at + charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at, + min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead, + grid_charge_suppressed_reason from ems.fn_load_planning_slots_full( $1::int, $2::timestamptz, $3::timestamptz, $4::numeric ) @@ -2882,6 +2961,12 @@ async def _load_slots( d, "charge_acquisition_buy_czk_kwh" ), charge_acquisition_cutoff_at=d.get("charge_acquisition_cutoff_at"), + min_buy_before_cutoff_czk_kwh=_slot_float_nullable( + d, "min_buy_before_cutoff_czk_kwh" + ), + pv_charge_wh_ahead=_slot_float_nullable(d, "pv_charge_wh_ahead"), + neg_buy_wh_ahead=_slot_float_nullable(d, "neg_buy_wh_ahead"), + grid_charge_suppressed_reason=d.get("grid_charge_suppressed_reason"), ) ) if not out: @@ -2960,6 +3045,10 @@ async def _save_planning_run( "heat_pump_setpoint_w": r.heat_pump_setpoint_w, "pv_a_curtailed_w": r.pv_a_curtailed_w, "expected_cost_czk": float(r.expected_cost_czk), + "cashflow_czk": float(r.cashflow_czk), + "battery_arbitrage_czk": float(r.battery_arbitrage_czk), + "penalty_czk": float(r.penalty_czk), + "green_bonus_czk": float(r.green_bonus_czk), "effective_buy_price": float(r.effective_buy_price), "effective_sell_price": float(r.effective_sell_price), "is_predicted_price": r.is_predicted_price, diff --git a/backend/tests/test_planning_charge_slot_selection.py b/backend/tests/test_planning_charge_slot_selection.py index bbc99b0..8b55f92 100644 --- a/backend/tests/test_planning_charge_slot_selection.py +++ b/backend/tests/test_planning_charge_slot_selection.py @@ -73,6 +73,108 @@ def _buy_min_next_n( return min(tail) if tail else None +def _pv_surplus_w(s: PlanningSlot) -> int: + return max(0, int(s.pv_a_forecast_w) + int(s.pv_b_forecast_w) - int(s.load_baseline_w)) + + +def _first_neg_sell_ord(slots: list[PlanningSlot]) -> int | None: + for i, s in enumerate(slots): + if float(s.sell_price) < 0: + return i + return None + + +def _apply_dynamic_grid_filter( + slots: list[PlanningSlot], + battery: SimpleNamespace, + current_soc_wh: float, + grid_slots: set[int], +) -> set[int]: + """Self-konzistentni filtr vrstvy B (kopie R__063 A2).""" + if not grid_slots: + return grid_slots + + degrad = float(getattr(battery, "degradation_cost_czk_kwh", 0.15) or 0.15) + first_neg = _first_neg_sell_ord(slots) + eta = float(getattr(battery, "charge_efficiency", 1.0) or 1.0) + max_p_w = float(getattr(battery, "max_charge_power_w", 0.0) or 0.0) + per_slot_wh = max_p_w * eta * INTERVAL_H + soc_max = float(battery.soc_max_wh) + deficit = max(0.0, soc_max - float(current_soc_wh)) + threshold = deficit * 0.6 + t_len = len(slots) + + pv_ahead = [0.0] * t_len + neg_ahead = [0.0] * t_len + for t in range(t_len): + pv_sum = 0.0 + neg_sum = 0.0 + for w2 in range(t, t_len): + if first_neg is not None and w2 >= first_neg: + break + s2 = slots[w2] + pv_s = _pv_surplus_w(s2) + if pv_s > 0 and (float(s2.sell_price) < 0 or float(s2.buy_price) < 0): + pv_sum += min(pv_s, max_p_w) * eta * INTERVAL_H + if float(s2.buy_price) < 0: + neg_sum += per_slot_wh + pv_ahead[t] = min(pv_sum, deficit) + neg_ahead[t] = neg_sum + slots[t].pv_charge_wh_ahead = pv_ahead[t] + slots[t].neg_buy_wh_ahead = neg_ahead[t] + + remaining = set(grid_slots) + acq_prev = -999.0 + for _ in range(5): + if not remaining: + break + total_wh = len(remaining) * per_slot_wh + if total_wh <= 0: + acq = min(float(s.buy_price) for s in slots) + else: + acq = sum(float(slots[t].buy_price) * per_slot_wh for t in remaining) / total_wh + if abs(acq - acq_prev) < 0.05: + break + acq_prev = acq + to_remove: set[int] = set() + for t in list(remaining): + s = slots[t] + if float(s.buy_price) < 0: + continue + if float(s.buy_price) > acq - degrad and pv_ahead[t] + neg_ahead[t] >= threshold: + to_remove.add(t) + slots[t].grid_charge_suppressed_reason = ( + "cheaper_pv_ahead" + if pv_ahead[t] >= neg_ahead[t] + else "cheaper_neg_buy_ahead" + ) + if not to_remove: + break + remaining -= to_remove + + cum_allowed = len(remaining) * per_slot_wh + pv0 = pv_ahead[0] if t_len else 0.0 + target_deficit = deficit - pv0 + if cum_allowed < target_deficit * 0.6: + suppressed = sorted( + [ + t + for t in grid_slots + if t not in remaining and slots[t].grid_charge_suppressed_reason + ], + key=lambda t: (float(slots[t].buy_price), t), + ) + for t in suppressed: + if float(slots[t].buy_price) >= 2 * acq_prev: + break + remaining.add(t) + slots[t].grid_charge_suppressed_reason = "safety_failsafe_unlock" + cum_allowed += per_slot_wh + if cum_allowed >= target_deficit * 0.6: + break + return remaining + + def _store_score(slots: list[PlanningSlot], t: int) -> float: s = slots[t] buy = float(s.buy_price) @@ -87,6 +189,7 @@ def _select_charge_slots( current_soc_wh: float, *, purchase_pricing_mode: str = "spot", + apply_dynamic_grid_filter: bool = True, ) -> set[int]: """Kopie logiky z ems.fn_load_planning_slots_full (charge mask).""" charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0) @@ -141,6 +244,7 @@ def _select_charge_slots( chg_pm = charge_target_wh - chg_am selected: set[int] = set() + grid_selected: set[int] = set() grid_filled_wh = 0.0 buf_mult = charge_buf if charge_buf > 0 else 1.0 @@ -185,6 +289,7 @@ def _select_charge_slots( if cum >= chg_am or per_slot_full_wh <= 0 or grid_am >= cap_am: break selected.add(t) + grid_selected.add(t) cum += per_slot_full_wh grid_am += 1 grid_filled_wh += cum @@ -210,6 +315,7 @@ def _select_charge_slots( if cum >= chg_pm or per_slot_full_wh <= 0 or grid_pm >= cap_pm: break selected.add(t) + grid_selected.add(t) cum += per_slot_full_wh grid_pm += 1 grid_filled_wh += cum @@ -217,6 +323,14 @@ def _select_charge_slots( for t, s in enumerate(slots): if float(s.buy_price) < 0: selected.add(t) + grid_selected.add(t) + + if apply_dynamic_grid_filter: + filtered_grid = _apply_dynamic_grid_filter( + slots, battery, current_soc_wh, grid_selected + ) + for t in grid_selected - filtered_grid: + selected.discard(t) elif purchase_pricing_mode == "fixed" and any( float(s.sell_price) > float(s.buy_price) + degrad for s in slots @@ -695,6 +809,115 @@ class SelectDischargeExportSlotsTests(unittest.TestCase): self.assertNotIn(1, discharge) +class DynamicGridFilterTests(unittest.TestCase): + def _range(self, start_h: int, end_h: int, **kwargs) -> list[PlanningSlot]: + base = datetime(2026, 5, 24, 0, 0, tzinfo=_PRAGUE) + out: list[PlanningSlot] = [] + for h in range(start_h, end_h): + for minute in (0, 15, 30, 45): + t = base.replace(hour=h, minute=minute).astimezone(timezone.utc) + out.append( + _slot( + interval_start=t, + hour_utc=t.hour, + buy=kwargs.get("buy", 4.5), + sell=kwargs.get("sell", 2.0), + pv=kwargs.get("pv_b", 0), + load=kwargs.get("load", 500), + ) + ) + if "pv_a" in kwargs: + out[-1] = PlanningSlot( + interval_start=t, + buy_price=float(kwargs.get("buy", 4.5)), + sell_price=float(kwargs.get("sell", 2.0)), + pv_a_forecast_w=int(kwargs["pv_a"]), + pv_b_forecast_w=int(kwargs.get("pv_b", 0)), + load_baseline_w=int(kwargs.get("load", 500)), + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + ) + return out + + def _home01_battery(self) -> SimpleNamespace: + return _battery(charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0) + + def _uniform_buy_slots(self, buy: float, n: int = 96) -> list[PlanningSlot]: + base = datetime(2026, 5, 24, 0, 0, tzinfo=_PRAGUE) + return [ + _slot( + buy=buy, + sell=2.0, + load=500, + interval_start=(base + timedelta(minutes=15 * i)).astimezone(timezone.utc), + ) + for i in range(n) + ] + + def test_home01_night_charge_before_neg_sell_pv_day(self) -> None: + slots = [ + *self._range(0, 5, buy=4.7, sell=2.9), + *self._range(5, 7, buy=5.0, sell=3.0, pv_b=400), + *self._range(7, 11, buy=4.5, sell=2.8, pv_a=3000, pv_b=2000), + *self._range(11, 14, buy=0.5, sell=-0.4, pv_a=6000, pv_b=5000), + *self._range(14, 17, buy=1.0, sell=-0.3, pv_a=5000, pv_b=4000), + *self._range(17, 19, buy=4.5, sell=3.0), + *self._range(19, 22, buy=6.5, sell=4.0), + *self._range(22, 24, buy=4.8, sell=3.0), + ] + selected = _select_charge_slots( + slots, self._home01_battery(), current_soc_wh=30_000.0 + ) + for i in range(88, 96): + self.assertNotIn(i, selected, f"slot {i} (noc) nema byt allow_grid_charge") + self.assertTrue(any(i in selected for i in range(44, 56))) + + def test_cloudy_day_no_pv_grid_unlock(self) -> None: + slots = self._uniform_buy_slots(4.5 + 0.1) + selected = _select_charge_slots( + slots, self._home01_battery(), current_soc_wh=30_000.0 + ) + self.assertGreater(len(selected), 4, "failsafe musel uvolnit nejake sloty") + + def test_ba81_fixed_tariff_mask_unchanged(self) -> None: + battery = _battery(charge_buf=1.3, uc_wh=12_500.0) + slots_fixed = self._uniform_buy_slots(buy=3.5) + selected_v1 = _select_charge_slots( + slots_fixed, + battery, + 5000.0, + purchase_pricing_mode="fixed", + apply_dynamic_grid_filter=False, + ) + selected_v2 = _select_charge_slots( + slots_fixed, + battery, + 5000.0, + purchase_pricing_mode="fixed", + ) + self.assertEqual(selected_v1, selected_v2) + + def test_kv1_block_export_unchanged(self) -> None: + """KV1: filtr vrstvy B beze zmeny u fixed tarifu (stejne jako BA81).""" + battery = _battery(charge_buf=1.3, uc_wh=12_500.0) + slots_fixed = self._uniform_buy_slots(buy=3.5) + selected_v1 = _select_charge_slots( + slots_fixed, + battery, + 5000.0, + purchase_pricing_mode="fixed", + apply_dynamic_grid_filter=False, + ) + selected_v2 = _select_charge_slots( + slots_fixed, + battery, + 5000.0, + purchase_pricing_mode="fixed", + ) + self.assertEqual(selected_v1, selected_v2) + + class FixedPurchasePricingTests(unittest.TestCase): def test_fixed_skips_grid_charge_when_no_sell_arbitrage(self) -> None: """Fixní buy bez výkupu nad buy+degrad → žádné grid nabíjení.""" diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 6035f47..9582d50 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -233,6 +233,10 @@ class PlanningDispatchMilpTests(unittest.TestCase): effective_buy_price=1.0, effective_sell_price=1.0, is_predicted_price=False, + cashflow_czk=1.0, + battery_arbitrage_czk=0.0, + penalty_czk=0.0, + green_bonus_czk=0.0, ) ] peer = [ @@ -256,6 +260,10 @@ class PlanningDispatchMilpTests(unittest.TestCase): effective_buy_price=1.0, effective_sell_price=1.0, is_predicted_price=False, + cashflow_czk=2.0, + battery_arbitrage_czk=0.0, + penalty_czk=0.0, + green_bonus_czk=0.0, ) ] cmp = _dispatch_result_comparison(active, 10, "v1", peer, 12, "v2") @@ -1222,7 +1230,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12") self.assertGreater( results[0].battery_setpoint_w, 5_500, @@ -1372,7 +1380,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 50.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12") self.assertEqual(len(results), len(slots)) def test_gen_cutoff_full_soc_neg_sell_with_pv_b_feasible(self) -> None: @@ -1436,7 +1444,7 @@ class NegativeSellPvChargeTests(unittest.TestCase): 55.0, operating_mode="AUTO", ) - self.assertEqual(snap.get("planner_build_tag"), "2026-05-26-neg-sell-bat-dump-extreme-buy-v11") + self.assertEqual(snap.get("planner_build_tag"), "2026-05-27-self-consistent-grid-mask-v12") self.assertEqual(len(results), len(slots)) def test_fixed_tariff_neg_sell_no_grid_export(self) -> None: @@ -2583,6 +2591,116 @@ class Home01RegressionTests(unittest.TestCase): self.assertGreaterEqual(results[i].grid_setpoint_w, -50) self.assertNotEqual(results[i].export_mode, "PV_SURPLUS") + @staticmethod + def _home01_run16522_slots() -> list[PlanningSlot]: + from test_planning_charge_slot_selection import ( + _battery as mask_battery, + _select_charge_slots, + _select_discharge_export_slots, + ) + from zoneinfo import ZoneInfo + + prague = ZoneInfo("Europe/Prague") + base = datetime(2026, 5, 24, 0, 0, tzinfo=prague) + hour_specs: list[tuple[int, int, dict]] = [ + (0, 5, {"buy": 4.7, "sell": 2.9}), + (5, 7, {"buy": 5.0, "sell": 3.0, "pv_b": 400}), + (7, 11, {"buy": 4.5, "sell": 2.8, "pv_a": 3000, "pv_b": 2000}), + (11, 14, {"buy": 0.5, "sell": -0.4, "pv_a": 6000, "pv_b": 5000}), + (14, 17, {"buy": 1.0, "sell": -0.3, "pv_a": 5000, "pv_b": 4000}), + (17, 19, {"buy": 4.5, "sell": 3.0}), + (19, 22, {"buy": 6.5, "sell": 4.0}), + (22, 24, {"buy": 4.8, "sell": 3.0}), + ] + slots: list[PlanningSlot] = [] + for h0, h1, kw in hour_specs: + for h in range(h0, h1): + for minute in (0, 15, 30, 45): + t = base.replace(hour=h, minute=minute).astimezone(timezone.utc) + slots.append( + PlanningSlot( + interval_start=t, + buy_price=float(kw["buy"]), + sell_price=float(kw["sell"]), + pv_a_forecast_w=int(kw.get("pv_a", 0)), + pv_b_forecast_w=int(kw.get("pv_b", 0)), + load_baseline_w=500, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + ) + ) + mb = mask_battery(charge_buf=1.3, uc_wh=64_000.0, soc_max_pct=95.0) + soc0 = 30_000.0 + charge = _select_charge_slots(slots, mb, soc0) + discharge = _select_discharge_export_slots(slots, mb, soc0, charge) + acq = ( + sum(float(slots[t].buy_price) for t in charge) / len(charge) + if charge + else min(float(s.buy_price) for s in slots) + ) + cutoff = min( + (slots[t].interval_start for t in discharge), + default=slots[-1].interval_start, + ) + for t, s in enumerate(slots): + s.allow_charge = t in charge or float(s.buy_price) < 0 + s.allow_discharge_export = t in discharge + s.charge_acquisition_buy_czk_kwh = acq + s.charge_acquisition_cutoff_at = cutoff + return slots + + def _home01_battery(self, soc: float = 30_000.0) -> SimpleNamespace: + b = _battery( + uc_wh=64_000.0, + min_pct=11.0, + arb_pct=20.0, + terminal_soc_value_factor=0.2, + ) + b.max_charge_power_w = 17_000 + b.max_discharge_power_w = 17_000 + b.charge_slot_buffer = 1.3 + b.planner_daytime_charge_target_enabled = True + return b + + def _home01_grid(self) -> SimpleNamespace: + return SimpleNamespace( + max_import_power_w=17_000, + max_export_power_w=13_500, + block_export_on_negative_sell=False, + purchase_pricing_mode="spot", + ) + + def test_home01_no_night_charge_before_pv_day(self) -> None: + """Pattern run 16522: 22:00-24:00 bez grid importu >15 kW pred PV dnem.""" + from zoneinfo import ZoneInfo + + slots = self._home01_run16522_slots() + results, _snap = self._solve_auto( + slots, + self._home01_battery(), + 30_000.0, + ) + prague = ZoneInfo("Europe/Prague") + for r in results: + h = r.interval_start.astimezone(prague).hour + if h in (22, 23): + self.assertLess( + r.grid_setpoint_w, + 15_000, + f"slot {r.interval_start}: grid={r.grid_setpoint_w} >= 15 kW", + ) + + def test_two_pass_converged_after_filter(self) -> None: + """Po self-konzistentni masce B: acquisition pass1 ~ pass2.""" + slots = self._home01_run16522_slots() + _results, snap = self._solve_auto(slots, self._home01_battery(), 30_000.0) + inputs = snap.get("inputs") or {} + self.assertTrue( + inputs.get("two_pass_converged"), + f"acquisition diverguje: {inputs}", + ) + class LoadFirstDispatchTests(unittest.TestCase): """Deye load-first: PV do spotřeby dřív než bc_pv/ge_pv z přebytku.""" diff --git a/backend/tests/test_planning_economics_columns.py b/backend/tests/test_planning_economics_columns.py new file mode 100644 index 0000000..c2c44aa --- /dev/null +++ b/backend/tests/test_planning_economics_columns.py @@ -0,0 +1,27 @@ +"""DispatchResult: nove ekonomicke sloupce (cashflow/arbitraz/penalty/bonus).""" +from __future__ import annotations + +import unittest +from dataclasses import fields + +from services.planning_engine import DispatchResult + + +class DispatchResultEconomicsFieldsTests(unittest.TestCase): + def test_has_new_economics_fields(self) -> None: + names = {f.name for f in fields(DispatchResult)} + for required in ( + "cashflow_czk", + "battery_arbitrage_czk", + "penalty_czk", + "green_bonus_czk", + ): + self.assertIn(required, names, f"DispatchResult missing field {required}") + + def test_legacy_expected_cost_czk_kept(self) -> None: + names = {f.name for f in fields(DispatchResult)} + self.assertIn("expected_cost_czk", names) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/migration/V081__planning_interval_economics.sql b/db/migration/V081__planning_interval_economics.sql new file mode 100644 index 0000000..2d277c9 --- /dev/null +++ b/db/migration/V081__planning_interval_economics.sql @@ -0,0 +1,25 @@ +-- Rozsireni ekonomickeho rozpadu planu (audit transparence: cashflow vs arbitraz vs penalizace vs bonus). +-- Drive byl v planning_interval jen expected_cost_czk = gi*buy - ge*sell (bez penalizaci a bez acquisition). + +alter table ems.planning_interval + add column if not exists cashflow_czk numeric, + add column if not exists battery_arbitrage_czk numeric, + add column if not exists penalty_czk numeric, + add column if not exists green_bonus_czk numeric; + +comment on column ems.planning_interval.cashflow_czk is + 'Net penezni tok ze site v slotu: gi*buy_price*h - ge*sell_price*h (Kc). ' + 'Kladne = platba EMS, zaporne = prijem. Shodne s expected_cost_czk (ponechano jako legacy).'; + +comment on column ems.planning_interval.battery_arbitrage_czk is + 'Marze z exportu baterie do site: ge_bat * (sell_price - acquisition_used) * h (Kc). ' + 'Kladne = zisk arbitraze (cena prodeje > vazeny nakup zasoby).'; + +comment on column ems.planning_interval.penalty_czk is + 'Soucet penalizaci v slotu (Kc): shortfall (peak_export, pv_charge, neg_sell_dump) + safety_deficit ' + '+ curtailment + commitment. Neviditelne v cashflow_czk, ale solver je optimalizuje.'; + +comment on column ems.planning_interval.green_bonus_czk is + 'Planovany zeleny bonus z vyroby poli s active green_bonus_czk_kwh (Kc). ' + 'pv_*_forecast_solver_w * green_bonus_czk_kwh * h, scitano pres vsechna pole se zelenym bonusem ' + 'platnym v slotu (ems.asset_pv_array.green_bonus_*).'; diff --git a/db/routines/R__034_fn_plan_explain_bundle.sql b/db/routines/R__034_fn_plan_explain_bundle.sql index ed47584..868812a 100644 --- a/db/routines/R__034_fn_plan_explain_bundle.sql +++ b/db/routines/R__034_fn_plan_explain_bundle.sql @@ -24,6 +24,7 @@ DECLARE v_ev JSONB; v_fc JSONB; v_ov JSONB; + v_econ JSONB; BEGIN IF p_site_id IS NULL THEN RETURN jsonb_build_object('error', 'site_id_required'); @@ -89,6 +90,49 @@ BEGIN AND pi.interval_start < v_win_end ) t; + select jsonb_build_object( + 'window_start_utc', v_slot, + 'window_end_utc', v_win_end, + 'total_import_kwh', coalesce(sum( + case when pi.grid_setpoint_w > 0 + then pi.grid_setpoint_w * 0.25 / 1000.0 else 0 end + ), 0), + 'total_export_kwh', coalesce(sum( + case when pi.grid_setpoint_w < 0 + then -pi.grid_setpoint_w * 0.25 / 1000.0 else 0 end + ), 0), + 'total_buy_cost_czk', coalesce(sum( + case when pi.grid_setpoint_w > 0 + then pi.grid_setpoint_w * pi.effective_buy_price * 0.25 / 1000.0 + else 0 end + ), 0), + 'total_sell_revenue_czk', coalesce(sum( + case when pi.grid_setpoint_w < 0 + then -pi.grid_setpoint_w * pi.effective_sell_price * 0.25 / 1000.0 + else 0 end + ), 0), + 'total_cashflow_czk', coalesce(sum(pi.cashflow_czk), 0), + 'total_battery_arbitrage_czk', coalesce(sum(pi.battery_arbitrage_czk), 0), + 'total_penalty_czk', coalesce(sum(pi.penalty_czk), 0), + 'total_green_bonus_czk', coalesce(sum(pi.green_bonus_czk), 0), + 'net_economic_czk', + coalesce(-sum(pi.cashflow_czk), 0) + + coalesce(sum(pi.battery_arbitrage_czk), 0) + - coalesce(sum(pi.penalty_czk), 0) + + coalesce(sum(pi.green_bonus_czk), 0), + 'neg_sell_export_slots', count(*) filter ( + where pi.effective_sell_price < 0 and pi.grid_setpoint_w < -500 + ), + 'first_grid_charge_slot_utc', min(pi.interval_start) filter ( + where pi.grid_setpoint_w > 500 + ) + ) + into v_econ + from ems.planning_interval pi + where pi.run_id = v_run.id + and pi.interval_start >= v_slot + and pi.interval_start < v_win_end; + SELECT to_jsonb(m.*) || jsonb_build_object('mode_name', d.name) INTO v_mode FROM ems.site_operating_mode m @@ -170,6 +214,7 @@ BEGIN 'ev_sessions_open', v_ev, 'forecast_correction_log_recent', v_fc, 'site_overrides_active_in_window', v_ov, + 'economics_summary', v_econ, 'ai_readme', jsonb_build_object( 'purpose', 'Data stačí k vysvětlení „proč plán v dalších hodinách vypadá takto“: ceny v řádcích intervalů, vstupy (baseline, PV), výstupy (bat/grid/EV/TČ), režim a síťové limity.', diff --git a/db/routines/R__037_fn_planning_run_commit.sql b/db/routines/R__037_fn_planning_run_commit.sql index 14a00a3..7fcd488 100644 --- a/db/routines/R__037_fn_planning_run_commit.sql +++ b/db/routines/R__037_fn_planning_run_commit.sql @@ -68,7 +68,11 @@ begin is_predicted_price, load_baseline_w, pv_a_forecast_raw_w, pv_b_forecast_raw_w, - pv_a_forecast_solver_w, pv_b_forecast_solver_w + pv_a_forecast_solver_w, pv_b_forecast_solver_w, + cashflow_czk, + battery_arbitrage_czk, + penalty_czk, + green_bonus_czk ) values ( v_run_id, (r.value->>'interval_start')::timestamptz, @@ -94,7 +98,11 @@ begin (r.value->>'pv_a_forecast_raw_w')::int, (r.value->>'pv_b_forecast_raw_w')::int, (r.value->>'pv_a_forecast_solver_w')::int, - (r.value->>'pv_b_forecast_solver_w')::int + (r.value->>'pv_b_forecast_solver_w')::int, + nullif(r.value->>'cashflow_czk', '')::numeric, + nullif(r.value->>'battery_arbitrage_czk', '')::numeric, + nullif(r.value->>'penalty_czk', '')::numeric, + nullif(r.value->>'green_bonus_czk', '')::numeric ); else insert into ems.planning_interval ( @@ -109,7 +117,11 @@ begin heat_pump_enabled, heat_pump_setpoint_w, pv_a_curtailed_w, expected_cost_czk, effective_buy_price, effective_sell_price, - is_predicted_price + is_predicted_price, + cashflow_czk, + battery_arbitrage_czk, + penalty_czk, + green_bonus_czk ) values ( v_run_id, (r.value->>'interval_start')::timestamptz, @@ -130,7 +142,11 @@ begin (r.value->>'expected_cost_czk')::numeric, (r.value->>'effective_buy_price')::numeric, (r.value->>'effective_sell_price')::numeric, - coalesce((r.value->>'is_predicted_price')::boolean, false) + coalesce((r.value->>'is_predicted_price')::boolean, false), + nullif(r.value->>'cashflow_czk', '')::numeric, + nullif(r.value->>'battery_arbitrage_czk', '')::numeric, + nullif(r.value->>'penalty_czk', '')::numeric, + nullif(r.value->>'green_bonus_czk', '')::numeric ); end if; end loop; diff --git a/db/routines/R__063_fn_load_planning_slots_full.sql b/db/routines/R__063_fn_load_planning_slots_full.sql index 1085bb8..744f3a6 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -32,7 +32,11 @@ returns table ( future_sell_opportunity_czk_kwh numeric, is_daytime_pv_surplus_slot boolean, charge_acquisition_buy_czk_kwh numeric, - charge_acquisition_cutoff_at timestamptz + charge_acquisition_cutoff_at timestamptz, + min_buy_before_cutoff_czk_kwh numeric, + pv_charge_wh_ahead numeric, + neg_buy_wh_ahead numeric, + grid_charge_suppressed_reason text ) language plpgsql volatile @@ -92,6 +96,14 @@ declare v_est_pv_cost numeric; v_export_window_start timestamptz; v_plan_day_prague date; + v_acq_v2 numeric; + v_acq_prev numeric := -999; + v_iter int; + v_affected int; + v_cum_allowed numeric; + v_pv_ahead_total numeric; + v_target_deficit numeric; + r_unlock record; begin v_plan_day_prague := (p_from at time zone 'Europe/Prague')::date; drop table if exists _ems_plan_slot_wk; @@ -310,7 +322,11 @@ begin add column if not exists buy_min_next_n numeric, add column if not exists store_score numeric, add column if not exists allow_grid_charge boolean default false, - add column if not exists export_window_start_at timestamptz; + add column if not exists export_window_start_at timestamptz, + add column if not exists min_buy_before_cutoff numeric, + add column if not exists pv_charge_wh_ahead numeric, + add column if not exists neg_buy_wh_ahead numeric, + add column if not exists grid_charge_suppressed_reason text; -- První výkupní okno **per kalendářní den** (Prague). Globální min přes dny by -- zablokoval NT grid nabíjení (včerejší večerní peak → dnešní 00–06 už „po okně“). @@ -512,6 +528,127 @@ begin update _ems_plan_slot_wk wk set allow_charge = true, allow_grid_charge = true where wk.buy_price < 0; + + -- Self-konzistentni filtr vrstvy B (spot): vyloucit drahe grid sloty, pokud PV / buy<0 + -- alternativa pokryje deficit SoC pred prvnim exportem. + update _ems_plan_slot_wk wk + set pv_charge_wh_ahead = sub.pv_wh_ahead, + neg_buy_wh_ahead = sub.neg_buy_wh_ahead, + min_buy_before_cutoff = sub.min_buy_ahead + from ( + select + wk.slot_ord, + least( + coalesce(sum( + case + when w2.slot_ord >= wk.slot_ord + and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord) + and w2.pv_surplus_w > 0 + and (w2.sell_price < 0 or w2.buy_price < 0) + then least(w2.pv_surplus_w::numeric, v_max_charge_w) + * v_charge_eff * 0.25 + else 0 + end + ), 0), + v_soc_max_wh - p_current_soc_wh + ) as pv_wh_ahead, + coalesce(sum( + case + when w2.slot_ord >= wk.slot_ord + and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord) + and w2.buy_price < 0 + then v_per_slot_charge_wh + else 0 + end + ), 0) as neg_buy_wh_ahead, + min( + case + when w2.slot_ord > wk.slot_ord + and (v_first_neg_sell_ord is null or w2.slot_ord < v_first_neg_sell_ord) + then w2.buy_price + else null + end + ) as min_buy_ahead + from _ems_plan_slot_wk wk + cross join _ems_plan_slot_wk w2 + group by wk.slot_ord + ) sub + where wk.slot_ord = sub.slot_ord; + + v_iter := 0; + loop + v_iter := v_iter + 1; + exit when v_iter > 5; + + select coalesce( + sum(wk.buy_price * v_per_slot_charge_wh) + filter ( + where wk.allow_grid_charge + and (v_first_neg_sell_ord is null or wk.slot_ord < v_first_neg_sell_ord) + ) + / nullif(sum(v_per_slot_charge_wh) + filter ( + where wk.allow_grid_charge + and (v_first_neg_sell_ord is null or wk.slot_ord < v_first_neg_sell_ord) + ), 0), + v_ref_buy_czk_kwh + ) + into v_acq_v2 + from _ems_plan_slot_wk wk; + + exit when abs(v_acq_v2 - v_acq_prev) < 0.05; + v_acq_prev := v_acq_v2; + + update _ems_plan_slot_wk wk + set allow_charge = false, + allow_grid_charge = false, + grid_charge_suppressed_reason = + case + when wk.pv_charge_wh_ahead + wk.neg_buy_wh_ahead + >= greatest(0, v_soc_max_wh - p_current_soc_wh) * 0.6 + then 'cheaper_pv_ahead' + else 'cheaper_neg_buy_ahead' + end + where wk.allow_grid_charge + and wk.buy_price > v_acq_v2 - v_degrad_czk_kwh + and wk.buy_price >= 0 + and ( + wk.pv_charge_wh_ahead + wk.neg_buy_wh_ahead + >= greatest(0, v_soc_max_wh - p_current_soc_wh) * 0.6 + ); + + get diagnostics v_affected = row_count; + exit when v_affected = 0; + end loop; + + select coalesce(sum(v_per_slot_charge_wh) filter (where wk.allow_grid_charge), 0) + into v_cum_allowed + from _ems_plan_slot_wk wk; + + select coalesce(min(wk.pv_charge_wh_ahead), 0) + into v_pv_ahead_total + from _ems_plan_slot_wk wk + where wk.slot_ord = 0; + + v_target_deficit := greatest(0, v_soc_max_wh - p_current_soc_wh) - v_pv_ahead_total; + + if v_cum_allowed < v_target_deficit * 0.6 then + for r_unlock in + select wk.slot_ord + from _ems_plan_slot_wk wk + where wk.grid_charge_suppressed_reason is not null + and wk.buy_price < 2 * v_acq_v2 + order by wk.buy_price, wk.slot_ord + loop + update _ems_plan_slot_wk wk + set allow_charge = true, + allow_grid_charge = true, + grid_charge_suppressed_reason = 'safety_failsafe_unlock' + where wk.slot_ord = r_unlock.slot_ord; + v_cum_allowed := v_cum_allowed + v_per_slot_charge_wh; + exit when v_cum_allowed >= v_target_deficit * 0.6; + end loop; + end if; elsif exists ( select 1 from _ems_plan_slot_wk w2 @@ -925,7 +1062,11 @@ begin ( extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 18 and w.pv_surplus_w > 0 - ) as is_daytime_pv_surplus_slot + ) as is_daytime_pv_surplus_slot, + w.min_buy_before_cutoff as min_buy_before_cutoff_czk_kwh, + coalesce(w.pv_charge_wh_ahead, 0) as pv_charge_wh_ahead, + coalesce(w.neg_buy_wh_ahead, 0) as neg_buy_wh_ahead, + w.grid_charge_suppressed_reason from _ems_plan_slot_wk w cross join night_tot nt ) @@ -949,7 +1090,11 @@ begin e.future_sell_opportunity_czk_kwh, e.is_daytime_pv_surplus_slot, v_charge_acquisition as charge_acquisition_buy_czk_kwh, - v_acquisition_cutoff as charge_acquisition_cutoff_at + v_acquisition_cutoff as charge_acquisition_cutoff_at, + e.min_buy_before_cutoff_czk_kwh, + e.pv_charge_wh_ahead, + e.neg_buy_wh_ahead, + e.grid_charge_suppressed_reason from enriched e order by e.slot_ord; end; diff --git a/docs/04-modules/planning-arbitrage-accounting.md b/docs/04-modules/planning-arbitrage-accounting.md index cf573fa..83fee38 100644 --- a/docs/04-modules/planning-arbitrage-accounting.md +++ b/docs/04-modules/planning-arbitrage-accounting.md @@ -114,6 +114,8 @@ Pro **home-01** při nabíjení 11:00–14:00 za ~0,7–0,9 Kč a výprodeji 19: 4. **`solve_dispatch_two_pass`:** pass 1 → vážený `buy` z `bc`+`gi` v `allow_charge` → pass 2; volá `run_daily_plan` / `run_rolling_replan` v AUTO. Snapshot: `acquisition_pass1_czk_kwh`, `acquisition_pass2_czk_kwh`, `two_pass_enabled`. 5. **Regrese:** `Home01RegressionTests` v `backend/tests/test_planning_dispatch_milp.py`; masky v `test_planning_charge_slot_selection.py`. 6. **Load-first (Deye, AUTO):** proměnné `pv_ld` / `pv_sp`, `bc_pv` / `bc_gi`; přebytek FVE jen `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi` (žádný fiktivní import při PV exportu). Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`. Test `LoadFirstDispatchTests`. +7. **Self-konzistentní vrstva B (`R__063`, 2026-05):** iterativní filtr v plpgsql — vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 % deficitu SoC` (levnější alternativa dál v horizontu). Failsafe unlock pokud výsledek nepokryje safety target. Důsledek: `acquisition_pass1 ~ acquisition_pass2` v drtivé většině případů. Nové debug sloupce: `min_buy_before_cutoff_czk_kwh`, `pv_charge_wh_ahead`, `neg_buy_wh_ahead`, `grid_charge_suppressed_reason` (`cheaper_pv_ahead` / `cheaper_neg_buy_ahead` / `safety_failsafe_unlock`). +8. **Ekonomická transparentnost plánu (`V081`, 2026-05):** `planning_interval` — `cashflow_czk`, `battery_arbitrage_czk`, `penalty_czk`, `green_bonus_czk`; `fn_plan_explain_bundle` → `economics_summary`; post-processing v `solve_dispatch()`. ### Co dál neřešit ad-hoc @@ -145,4 +147,4 @@ Očekávání: SoC před večerem **70–90 %** po levném pásmu; večer **expo --- -*Poslední aktualizace: 2026-05 — LP-first přestavba (masky B/A, two-pass acquisition, explicitní ge_pv/ge_bat, load-first Deye). Po deployi: `solver_params.inputs.two_pass_enabled` na novém `planning_run`.* +*Poslední aktualizace: 2026-05-27 — self-konzistentní grid maska B (v12), ekonomické sloupce v `planning_interval`, `economics_summary` v explain bundle. Po deployi: `PLANNER_BUILD_TAG=2026-05-27-self-consistent-grid-mask-v12`, `solver_params.objective_terms[].grid_charge_suppressed_reason`.* diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 2e230dd..b1106db 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -12,7 +12,7 @@ - **SoC kontinuita a export z baterie:** `soc[t]` klesá při **`bd[t]` i `ge_bat[t]`** (vybíjení do domu i do sítě). Bez `ge_bat` v bilanci SoC LP „exportovalo“ bez vybití — arbitrážní dump v pozdních slotech místo ranního peaku. - **Masky `allow_charge` / `allow_discharge_export` (tenký anti-mikrocyklus):** generuje `ems.fn_load_planning_slots_full` (`R__063`). Ekonomiku primárně řídí LP podle efektivních cen; masky jen omezují počet slotů pro grid nabíjení / export baterie. - **PV-surplus (vrstva A):** ranking dle **`store_score DESC`** = `future_sell_opportunity − sell − max(0, buy−sell)`; jen sloty s `sell ≥ buy − degradation`. Kumulativní PV pokrývá `grid_target` (deficit SoC, nad `reserve_soc` bez násobení `charge_slot_buffer`). Zbytek → `allow_charge=false` (PV jen do sítě / `bc ≤ pv_surplus` v LP). - - **Grid ze sítě (vrstva B, před FVE):** výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`. + - **Grid ze sítě (vrstva B, před FVE):** výchozí **AM/PM 50/50** z `grid_target × charge_slot_buffer` (do `soc_max`); **nevyčerpaný AM Wh přejde do PM** (`R__063`). **Spot:** výběr **nejlevnější `buy`** (den plánu → před exportním oknem → `buy ASC`); navíc všechny sloty s **`buy < 0`** → `allow_grid_charge`. Po výběru AM/PM běží **iterativní self-konzistentní filtr** (vyloučí drahé grid sloty, pokud `pv_charge_wh_ahead + neg_buy_wh_ahead >= 60 %` deficitu SoC; failsafe unlock). Debug: `grid_charge_suppressed_reason`. **Fixní tarif (BA81):** stejný AM/PM rozpočet, ale pořadí podle **`slot_ord`** (buy konstantní), jen pokud v horizontu existuje **`sell > buy + degradation`**; jinak jen PV vrstva A. Cap slotů: `ceil(budget/per_slot_wh) × charge_slot_buffer`. **`charge_acquisition`:** vážený `buy` u `allow_grid_charge` před 1. exportem; two-pass v `planning_engine.py`. - **PV vrstva A:** při `sell ≥ 0` jen pokud `sell ≥ future_sell_opportunity − degradation` (držet FVE na večerní peak). Při **`sell < 0`** vrstva A **bez** tohoto filtru (nabít z FVE v záporném výkupním okně). Historie: [`docs/planning-changelog.md`](../planning-changelog.md). - **LP (AUTO):** objective explicitně `−ge_pv×sell − ge_bat×sell + ge_bat×acquisition` v exportních slotech; **bez** cross-slot vynucení `ge_pv ≥ surplus`. Guard FVE: `ge_pv=0` jen pokud `sell < charge_acquisition − degrad` (ne `sell < buy` ve slotu). Viz [`planning-arbitrage-accounting.md`](planning-arbitrage-accounting.md). - **Load-first (Deye, AUTO):** proměnné `pv_ld` (PV → load+EV+TČ), `pv_sp` (přebytek), `bc_pv` / `bc_gi`. Plná bilance `pv_a + pv_b + gi + bd = load + ev + hp + bc + ge`; `bc_pv + ge_pv ≤ pv_sp`; `gi ≤ load + bc_gi`; mimo `allow_discharge_export`: `bd ≤ load − pv_ld` a **`pv_ld ≥ load − gi − bd`**. Snapshot: `load_first_enabled=true`. Test `LoadFirstDispatchTests`. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 083e8b2..a99266e 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,20 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-05-27 — self-konzistentní grid maska B + ekonomický rozpad plánu (v12) + +**Problém (home-01, run 16522, tag v11):** Noční grid nabíjení (23:30–23:45, buy ~4,8 Kč) při `acquisition_pass1≈4,81` / `pass2≈0,84`, `two_pass_converged=false`; 26 slotů export při `sell<0`. + +**Oprava (tag `2026-05-27-self-consistent-grid-mask-v12`):** + +- **`R__063`:** iterativní filtr vrstvy B (spot) + sloupce `pv_charge_wh_ahead`, `neg_buy_wh_ahead`, `grid_charge_suppressed_reason`, `min_buy_before_cutoff_czk_kwh`; failsafe unlock. +- **`V081`:** `planning_interval.cashflow_czk`, `battery_arbitrage_czk`, `penalty_czk`, `green_bonus_czk`; commit přes `fn_planning_run_commit`. +- **`planning_engine.py`:** post-processing ekonomiky, `solver_params.objective_terms` rozšíření; `fn_plan_explain_bundle` → `economics_summary`. + +**Ověření:** `pytest backend/tests/test_planning_economics_columns.py`, `DynamicGridFilterTests`, `Home01RegressionTests::test_home01_no_night_charge_before_pv_day`, `test_two_pass_converged_after_filter`; po deploy MCP: `grid_charge_suppressed_reason` ve `fn_load_planning_slots_full`, `two_pass_converged=true` na novém run. + +--- + ## 2026-05-26 (o) — home-01: neg. výkup bez placeného exportu FVE + dump baterie před extrémním buy **Problém (run 16480, tag v10):** Po ranním nabití na `soc_max` solver při `sell<0` exportoval **celý PV přebytek** (~9 kW, `PV_SURPLUS`) — binárka `w_pv_full_neg` povolila `ge_pv ≤ pv_surplus` místo jen ventilu pole B. Zároveň `ge_bat=0` blokoval výboj baterie před oknem `buy ≤ −2` (round-trip arbitráž).