diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 906258a..1d22ad4 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -71,7 +71,7 @@ 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-06-01-spot-grid-charge-at-acq-buy-v61" +PLANNER_BUILD_TAG = "2026-06-01-kv1-fixed-night-self-consume-v62" # Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). DAWN_LOW_PV_NO_CURTAIL_W = 1500 # BA81/KV1: PV→bat jen v těsné blízkosti nejnižšího sell v horizontu (≈ poledne), ne při ~3 Kč ráno. @@ -1833,10 +1833,12 @@ def _night_self_consume_discourage_import_indices( evening_push_ts: set[int], charge_acquisition_czk_kwh: float, min_spread: float, + purchase_fixed: bool = False, ) -> set[int]: """ Noční sloty mimo evening_push: penalizace importu pro dům (preferovat bd). v45: celé noční okno, ne jen evening_early_export_ban subset. + KV1: buy ≈ acq (konstantní ~6,35) — jinak prázdná množina, síť místo baterie v noci. """ out: set[int] = set() for t, s in enumerate(slots): @@ -1844,11 +1846,15 @@ def _night_self_consume_discourage_import_indices( continue if not _in_night_battery_export_window(s): continue - buy_t = float(s.buy_price) - if buy_t <= float(charge_acquisition_czk_kwh) + float(min_spread): - continue if float(s.load_baseline_w) <= 0: continue + buy_t = float(s.buy_price) + if purchase_fixed: + if buy_t >= 0.0: + out.add(t) + continue + if buy_t <= float(charge_acquisition_czk_kwh) + float(min_spread): + continue out.add(t) return out @@ -2836,6 +2842,7 @@ def solve_dispatch( evening_push_ts=evening_push_ts, charge_acquisition_czk_kwh=charge_acquisition_czk_kwh, min_spread=float(degradation_cost_effective), + purchase_fixed=purchase_fixed_pre, ) post_evening_push_night_ts = _post_evening_push_night_self_consume_indices( slots, evening_push_ts @@ -4123,7 +4130,10 @@ def solve_dispatch( # Spot (home-01): buy > min ne-záporného buy v horizontu. # Fixní tarif (KV1): navíc buy > charge_acquisition (konstantní buy ≈ ref). expensive_import_slot = buy_t > ref_buy_horizon + min_spread - if fixed_tariff_like_pre: + if purchase_fixed_pre and buy_t >= 0.0: + # KV1/BA81: buy skoro konstantní — buy > acq nikdy neplatí, jinak v noci import za 6 Kč. + expensive_import_slot = True + elif fixed_tariff_like_pre: expensive_import_slot = expensive_import_slot or ( buy_t > charge_acquisition_czk_kwh + min_spread ) diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 80d53a8..d3334cb 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -1833,6 +1833,88 @@ class NegativeSellPvChargeTests(unittest.TestCase): "sell nad min horizontu: žádné grid nabíjení", ) + def test_kv1_fixed_night_uses_battery_not_grid_import(self) -> None: + """v62: po večerním push nocí bd≥load, ne import za fixní buy.""" + prague = ZoneInfo("Europe/Prague") + push = PlanningSlot( + interval_start=datetime(2026, 6, 1, 18, 45, tzinfo=prague).astimezone( + timezone.utc + ), + buy_price=6.353, + sell_price=9.61, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=400, + ev1_connected=False, + ev2_connected=False, + allow_charge=False, + allow_discharge_export=True, + charge_acquisition_buy_czk_kwh=6.353, + ) + night = PlanningSlot( + interval_start=datetime(2026, 6, 1, 23, 0, tzinfo=prague).astimezone( + timezone.utc + ), + buy_price=6.353, + sell_price=3.09, + pv_a_forecast_w=0, + pv_b_forecast_w=0, + load_baseline_w=800, + ev1_connected=False, + ev2_connected=False, + allow_charge=False, + allow_discharge_export=False, + charge_acquisition_buy_czk_kwh=6.353, + ) + battery = _battery(uc_wh=12_500.0, min_pct=10.0, arb_pct=30.0) + battery.max_discharge_power_w = 6250 + hp = SimpleNamespace( + rated_heating_power_w=0, + tuv_min_temp_c=45.0, + tuv_target_temp_c=55.0, + ) + grid = SimpleNamespace( + max_import_power_w=17_000, + max_export_power_w=8000, + block_export_on_negative_sell=True, + purchase_pricing_mode="fixed", + ) + vehicles = [ + SimpleNamespace( + max_charge_power_w=0, + battery_capacity_kwh=1.0, + default_target_soc_pct=80.0, + ), + SimpleNamespace( + max_charge_power_w=0, + battery_capacity_kwh=1.0, + default_target_soc_pct=80.0, + ), + ] + soc0 = 0.35 * battery.usable_capacity_wh + results, _, _ = solve_dispatch( + [push, night], + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + operating_mode="AUTO", + ) + r_night = results[1] + self.assertLess( + r_night.battery_setpoint_w, + -200, + "noc po push: výdej z baterie na dům", + ) + self.assertLessEqual( + r_night.grid_setpoint_w, + 400, + "noc: ne plný import za 6,35 Kč", + ) + def test_fixed_evening_push_no_charge_at_peak_sell(self) -> None: """v59: večerní push — při sell>buy ne nabíjet, jen vybíjet.""" prague = ZoneInfo("Europe/Prague") diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 8493def..0638c60 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,16 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-01 — KV1: noc z baterie, ne import za 6,35 Kč (v62) + +**Problém:** Po večerním vývozu (~32 % SoC) plán **22:00–06:00** krmil dům ze **sítě** (`grid ~260 W`, `bat 0`) místo z baterie. Fixní **buy ≈ charge_acquisition ≈ 6,35** → `expensive_import_slot` nikdy true → neplatilo `bd ≥ load` ani noční penalizace importu (`buy > acq` je false). + +**Změna (v62):** u **`purchase_pricing_mode=fixed`**: `expensive_import_slot = true` (buy ≥ 0); `_night_self_consume_discourage_import_indices` zahrne noční sloty i při **buy = acq**. + +Tag **`2026-06-01-kv1-fixed-night-self-consume-v62`**. + +--- + ## 2026-06-01 — home-01: grid jen při buy ≤ acquisition (v61, zrušeno v60) **Problém:** **19:00** nabíjení za **buy ~5,5** při **`charge_acquisition ~3,25`** z rána → falešně ziskový večerní export. **v60** (`sell < buy` ve slotu) bylo **špatně**: u spotu (a často u fixního tarifu) je **`sell < buy` normální** (marže distributora) — arbitráž je **mezi sloty**, ne v jedné čtvrthodině.