diff --git a/backend/services/planning/db_io.py b/backend/services/planning/db_io.py index dec0fa7..f6aff1a 100644 --- a/backend/services/planning/db_io.py +++ b/backend/services/planning/db_io.py @@ -137,6 +137,7 @@ async def _load_site_context(site_id: int, db): vehicles.append( SimpleNamespace( max_charge_power_w=int(v["max_charge_power_w"]), + min_power_w=int(v.get("min_power_w") or 0), battery_capacity_kwh=float(v["battery_capacity_kwh"]), default_target_soc_pct=float(v["default_target_soc_pct"]), ) @@ -145,6 +146,7 @@ async def _load_site_context(site_id: int, db): vehicles.append( SimpleNamespace( max_charge_power_w=0, + min_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0, ) diff --git a/backend/services/planning/solver_v2.py b/backend/services/planning/solver_v2.py index bffb1cd..0afa45f 100644 --- a/backend/services/planning/solver_v2.py +++ b/backend/services/planning/solver_v2.py @@ -28,9 +28,17 @@ # vede k "nabít plným výkonem hned, pak řezat A" — emergentně, bez rampy. # - oportunistické EV („měkký cíl"): nad tvrdý target smí auto vzít až # headroom_wh (do 100 %), oceněno opportunistic_value_czk_kwh (= budoucí -# ušetřené nabíjení, DB) — kupuje jen velmi levnou/zápornou energii. -# Dekompozice Σ(EV energie) == needed − unmet + opp zároveň stropuje -# celkovou energii do auta (dřív při buy<0 bez stropu). +# ušetřené nabíjení, session override → vozidlo, DB) — kupuje jen velmi +# levnou/zápornou energii. Dekompozice Σ(EV energie) == needed − unmet + opp +# zároveň stropuje celkovou energii do auta (dřív při buy<0 bez stropu); +# opp vrstva NENÍ vázaná deadline (auto bývá doma dál, odjezd řeší rolling +# replan); bez session je EV == 0 (stop-session). Deadline suma jde po +# slot PŘED deadline (slot začínající v deadline už nepatří „do deadline"). +# - min. výkon wallboxu (asset_ev_charger.min_power_w, 6 A ≈ 1380 W): +# binárka ev_on → setpoint ∈ {0} ∪ [min_power_w, max]; ev_direct ≤ gi + PV +# (fyzikální split direct/via_bat). Reporting: kWh přes ev_via_bat plní +# battery_arbitrage_czk oportunitní cenou (min sell exportního slotu dne, +# jinak terminal value) — slotový buy pro ně neplatí. # - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve → # reserve+noc, 6–19 h) platí za slot nájem buy×faktor (DB # planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva @@ -58,13 +66,14 @@ from services.planning.constants import ( from services.planning.types import ( DispatchResult, PlanningSlot, + _prague_calendar_date, _prague_dow_hour, ) from services.planning.heuristics import _dispatch_grid_setpoint_w logger = logging.getLogger(__name__) -V2_BUILD_TAG = "v2-clean-2026-06-11" +V2_BUILD_TAG = "v2-ev-accounting-2026-06-12" # Cena za vypnutí GEN portu (mikroinvertory pole B): reálné riziko/opotřebení # cyklování stykače — drobná, ale nenulová, aby cutoff platil jen při sell < 0. @@ -166,6 +175,10 @@ def solve_dispatch_v2( ] ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH) ev_opp: list = [] # (var, value_czk_kwh) — energie nad target (měkký cíl) + # min. výkon wallboxu (IEC 61851: 6 A ≈ 1380 W) — setpoint ∈ {0} ∪ [min, max] + ev_min_w = [ + max(0.0, float(getattr(vehicles[e], "min_power_w", 0) or 0)) for e in range(EV) + ] nb_buffer_wh = [max(0.0, float(s.night_baseload_buffer_wh or 0.0)) for s in slots] safety_risk = float(getattr(battery, "planner_safety_soc_risk_factor", 0.0) or 0.0) safety_tgt_wh = [ @@ -222,6 +235,11 @@ def solve_dispatch_v2( prob += bc_gi[t] <= gi[t], f"bcgi_src_{t}" # vybíjení kryje dům + EV-via-bat + export z baterie prob += ge_bat[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t], f"bd_split_{t}" + # ev_direct fyzicky jen ze sítě + PV (ne z baterie) — split direct/via_bat + # není arbitrární, ekonomiku nemění (bilance platí stejně) + prob += ( + pulp.lpSum(ev_direct[e][t] for e in range(EV)) <= gi[t] + pv_a_net + pv_b_eff + ), f"evd_src_{t}" # zákaz současného importu a exportu prob += gi[t] <= max_imp * y_imp[t], f"imp_excl_{t}" @@ -245,15 +263,20 @@ def solve_dispatch_v2( if float(s.sell_price) < 0.0 and block_neg_sell: prob += ge_pv[t] + ge_bat[t] == 0, f"neg_sell_block_{t}" - # EV dostupnost + # EV dostupnost + min. výkon wallboxu (binárka jen kde je min > 0) for e in range(EV): if not _connected(e, t): prob += ev_direct[e][t] == 0 prob += ev_via_bat[e][t] == 0 else: - prob += ev_direct[e][t] + ev_via_bat[e][t] <= float( - vehicles[e].max_charge_power_w - ) + ev_max_w = float(vehicles[e].max_charge_power_w) + ev_total = ev_direct[e][t] + ev_via_bat[e][t] + if 0 < ev_min_w[e] <= ev_max_w: + on = pulp.LpVariable(f"evon_{e}_{t}", cat=pulp.LpBinary) + prob += ev_total >= ev_min_w[e] * on, f"ev_min_{e}_{t}" + prob += ev_total <= ev_max_w * on, f"ev_max_{e}_{t}" + else: + prob += ev_total <= ev_max_w # provozní režimy (tvrdé constraints dle operating-modes.md) if om == "SELF_SUSTAIN": @@ -266,28 +289,40 @@ def solve_dispatch_v2( prob += ge_pv[t] + ge_bat[t] == 0 prob += bd[t] == 0 - # EV deadline (s placeným slackem místo infeasibility) + # EV deadline (s placeným slackem místo infeasibility) + měkký cíl. + # Bez session není mandát nabíjet: připojené auto bez session (stop-session, + # golden fixtures s vynulovanými sessions) nesmí při buy<0 „pumpovat" energii. for e in range(EV): sess = ev_sessions[e] if e < len(ev_sessions) else None - if sess is None or not getattr(sess, "energy_needed_wh", 0): + if sess is None: + for t in range(T): + if _connected(e, t): + prob += ev_direct[e][t] == 0, f"ev_nosess_d_{e}_{t}" + prob += ev_via_bat[e][t] == 0, f"ev_nosess_b_{e}_{t}" continue - t_dl = next( - (t for t in range(T) if slots[t].interval_start >= sess.target_deadline), - T - 1, - ) - unmet = pulp.LpVariable(f"ev_unmet_{e}", 0, float(sess.energy_needed_wh)) + needed = max(0.0, float(getattr(sess, "energy_needed_wh", 0.0) or 0.0)) + unmet = pulp.LpVariable(f"ev_unmet_{e}", 0, needed) ev_unmet.append(unmet) - prob += ( - pulp.lpSum( - (ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H - for t in range(t_dl + 1) - if _connected(e, t) + if needed > 0: + # první slot s interval_start >= deadline už do deadline NEPATŘÍ + # (slot [deadline, deadline+15min) dodává energii až po odjezdu) + t_dl = next( + (t for t in range(T) if slots[t].interval_start >= sess.target_deadline), + T, ) - + unmet - >= float(sess.energy_needed_wh) - ), f"ev_deadline_{e}" + prob += ( + pulp.lpSum( + (ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H + for t in range(t_dl) + if _connected(e, t) + ) + + unmet + >= needed + ), f"ev_deadline_{e}" - # měkký cíl: dekompozice celkové energie == needed − unmet + opp + # měkký cíl: dekompozice celkové energie == needed − unmet + opp. + # Oportunistická vrstva NENÍ omezená deadline — auto bývá doma dál, + # odjezd řeší rolling replan (rozhodnutí 2026-06-12). headroom = max(0.0, float(getattr(sess, "headroom_wh", 0.0) or 0.0)) opp_val = float(getattr(sess, "opportunistic_value_czk_kwh", 0.0) or 0.0) opp = pulp.LpVariable(f"ev_opp_{e}", 0, headroom if opp_val > 0 else 0.0) @@ -298,7 +333,7 @@ def solve_dispatch_v2( for t in range(T) if _connected(e, t) ) - == float(sess.energy_needed_wh) - unmet + opp + == needed - unmet + opp ), f"ev_total_{e}" # TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika) @@ -384,9 +419,32 @@ def solve_dispatch_v2( v = pulp.value(var) return float(v) if v is not None else 0.0 + # Reporting EV-via-bat: kWh do auta z baterie neplatí slotový buy (jdou + # z baterie), ale ušlou příležitost. Aproximace oportunitní ceny: nejnižší + # sell slotu, kde plán exportuje, v témže pražském dni; bez exportu ten den + # terminal value (Kč/kWh). Plní battery_arbitrage_czk (dřív konstantní 0). + day_min_export_sell: dict[Any, float] = {} + for t in range(T): + if _val(ge_pv[t]) + _val(ge_bat[t]) >= 1.0: + d_key = _prague_calendar_date(slots[t]) + sp = float(slots[t].sell_price) + if d_key not in day_min_export_sell or sp < day_min_export_sell[d_key]: + day_min_export_sell[d_key] = sp + results: list[DispatchResult] = [] for t in range(T): s = slots[t] + via1_w = _val(ev_via_bat[0][t]) if EV > 0 else 0.0 + via2_w = _val(ev_via_bat[1][t]) if EV > 1 else 0.0 + via_kwh = (via1_w + via2_w) * wh + if via_kwh > 1e-9: + opp_price = max( + 0.0, + day_min_export_sell.get(_prague_calendar_date(s), terminal * 1000.0), + ) + arb_czk = via_kwh * opp_price + else: + arb_czk = 0.0 bc_tot = _val(bc_pv[t]) + _val(bc_gi[t]) bd_v = _val(bd[t]) batt_w = round(bc_tot - bd_v) @@ -434,8 +492,8 @@ def solve_dispatch_v2( if EV > 1 and s.ev2_connected else None ), - ev1_via_bat_w=round(_val(ev_via_bat[0][t])) if EV > 0 else 0, - ev2_via_bat_w=round(_val(ev_via_bat[1][t])) if EV > 1 else 0, + ev1_via_bat_w=round(via1_w), + ev2_via_bat_w=round(via2_w), heat_pump_enabled=hp_on, heat_pump_setpoint_w=int(rated_hp) if hp_on else 0, pv_a_curtailed_w=round(_val(ca[t])), @@ -444,7 +502,7 @@ def solve_dispatch_v2( effective_sell_price=float(s.sell_price), is_predicted_price=bool(s.is_predicted_price), cashflow_czk=round(cash_t, 4), - battery_arbitrage_czk=0.0, + battery_arbitrage_czk=round(arb_czk, 4), penalty_czk=round(pen_t, 4), green_bonus_czk=float(getattr(s, "green_bonus_czk_per_slot", 0.0) or 0.0), ) @@ -462,6 +520,7 @@ def solve_dispatch_v2( "gen_cutoff_available": gen_cutoff_avail, "slot_count": T, "ev_sessions": sum(1 for x in ev_sessions if x is not None), + "ev_min_power_w": ev_min_w, "masks_ignored": True, "night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0), "pv_risk_frontload_czk_kwh": frontload if neg_idx else 0.0, diff --git a/backend/tests/test_solver_v2.py b/backend/tests/test_solver_v2.py index 890b33b..0bbd0fa 100644 --- a/backend/tests/test_solver_v2.py +++ b/backend/tests/test_solver_v2.py @@ -66,7 +66,16 @@ _VEHICLES = [ _BASE = datetime(2026, 6, 10, 0, 0, tzinfo=timezone.utc) -def _solve(slots, *, battery=None, grid=None, ev_sessions=(None, None), soc0=None, mode="AUTO"): +def _solve( + slots, + *, + battery=None, + grid=None, + ev_sessions=(None, None), + soc0=None, + mode="AUTO", + vehicles=None, +): bat = battery or _battery() return solve_dispatch_v2( slots, @@ -74,7 +83,7 @@ def _solve(slots, *, battery=None, grid=None, ev_sessions=(None, None), soc0=Non _HP, grid or _grid(), list(ev_sessions), - _VEHICLES, + vehicles if vehicles is not None else _VEHICLES, soc0 if soc0 is not None else 0.5 * bat.usable_capacity_wh, 50.0, operating_mode=mode, @@ -296,6 +305,144 @@ class EvOpportunisticTests(unittest.TestCase): self.assertLessEqual(delivered, 3000.0 + 1.0) +class EvAccountingTests(unittest.TestCase): + """EV účtování 2026-06-12: deadline boundary, stop-session, fyzikální split, + min. výkon wallboxu, opp po deadline, battery_arbitrage_czk reporting.""" + + def test_deadline_boundary_slot_excluded(self) -> None: + # slot začínající přesně v deadline (slot 4) už do deadline nepatří; + # levné sloty 4..7 nesmí krýt tvrdý cíl (dřív off-by-one t_dl+1) + slots = [ + _slot(_BASE, i, buy=5.0 if i < 4 else 0.5, sell=0.2, ev1=True) + for i in range(8) + ] + session = SimpleNamespace( + target_deadline=_BASE + timedelta(hours=1), # = start slotu 4 + energy_needed_wh=4000.0, + headroom_wh=0.0, + opportunistic_value_czk_kwh=0.0, + ) + results, _, snap = _solve(slots, ev_sessions=(session, None)) + before = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:4]) + after = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:]) + self.assertGreaterEqual(before, 4000.0 - 1.0, "tvrdý cíl jen sloty PŘED deadline") + self.assertLessEqual(after, 1.0, "slot v deadline a dál nekryje tvrdý cíl") + self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0]) + + def test_stop_session_zero_everywhere(self) -> None: + # needed 0 + opp 0 (stop-session) → EV nula i při záporných cenách + slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)] + session = SimpleNamespace( + target_deadline=_BASE + timedelta(hours=2), + energy_needed_wh=0.0, + headroom_wh=0.0, + opportunistic_value_czk_kwh=0.0, + ) + results, _, _ = _solve(slots, ev_sessions=(session, None)) + for r in results: + self.assertEqual(r.ev1_setpoint_w or 0, 0) + + def test_no_session_zero_even_at_negative_buy(self) -> None: + # připojené auto BEZ session nemá mandát nabíjet (golden fixtures) + slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)] + results, _, _ = _solve(slots, ev_sessions=(None, None)) + for r in results: + self.assertEqual(r.ev1_setpoint_w or 0, 0) + + def test_ev_direct_within_grid_plus_pv(self) -> None: + # fyzikální split: direct (= setpoint − via_bat) nesmí překročit gi + PV + slots = [ + _slot(_BASE, i, buy=2.0, sell=1.0, pv_a=(3000 if i < 4 else 0), ev1=True) + for i in range(12) + ] + bat = _battery() + session = SimpleNamespace( + target_deadline=_BASE + timedelta(hours=3), + energy_needed_wh=10000.0, + headroom_wh=0.0, + opportunistic_value_czk_kwh=0.0, + ) + results, _, _ = _solve( + slots, battery=bat, soc0=0.9 * bat.usable_capacity_wh, + ev_sessions=(session, None), + ) + for i, r in enumerate(results): + direct = (r.ev1_setpoint_w or 0) - r.ev1_via_bat_w + gi_w = max(0, r.grid_setpoint_w) + pv_w = slots[i].pv_a_forecast_w + slots[i].pv_b_forecast_w + self.assertLessEqual(direct, gi_w + pv_w + 2, f"slot {i}: direct > gi+pv") + + def test_min_power_setpoints_zero_or_above_min(self) -> None: + # wallbox min 1380 W (6 A): setpoint ∈ {0} ∪ [1380, max] — žádné 400–900 W + vehicles = [ + SimpleNamespace( + max_charge_power_w=11_000, min_power_w=1380, + battery_capacity_kwh=60.0, default_target_soc_pct=80.0, + ), + _VEHICLES[1], + ] + # ceny nutí rozprostřít malé množství energie → bez binárky by vyšlo ~86 W/slot + slots = [_slot(_BASE, i, buy=2.0 + 0.01 * i, sell=1.0, ev1=True) for i in range(8)] + session = SimpleNamespace( + target_deadline=_BASE + timedelta(hours=2), + energy_needed_wh=690.0, # 2 sloty × 1380 W × 0.25 h + headroom_wh=0.0, + opportunistic_value_czk_kwh=0.0, + ) + results, _, _ = _solve(slots, ev_sessions=(session, None), vehicles=vehicles) + delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results) + self.assertGreaterEqual(delivered, 690.0 - 1.0) + for i, r in enumerate(results): + sp = r.ev1_setpoint_w or 0 + self.assertTrue( + sp == 0 or sp >= 1379, + f"slot {i}: setpoint {sp} W je pod minimem wallboxu", + ) + + def test_opportunistic_after_deadline_allowed(self) -> None: + # ROZHODNUTO 2026-06-12: opp vrstva NENÍ omezená deadline — záporné ceny + # po deadline smí téct do auta (odjezd řeší rolling replan) + slots = [ + _slot(_BASE, i, buy=(3.0 if i < 4 else -1.5), sell=(1.0 if i < 4 else -0.5), + ev1=True, load=300) + for i in range(16) + ] + session = SimpleNamespace( + target_deadline=_BASE + timedelta(hours=1), # slot 4 + energy_needed_wh=2000.0, + headroom_wh=20000.0, + opportunistic_value_czk_kwh=1.0, + ) + results, _, snap = _solve(slots, ev_sessions=(session, None)) + after_deadline = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:]) + total = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results) + self.assertGreater(after_deadline, 0.0, "opp po deadline musí zůstat povolené") + self.assertLessEqual(total, 2000.0 + 20000.0 + 1.0, "strop needed + headroom") + self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0.0) + + def test_battery_arbitrage_reported_for_via_bat(self) -> None: + # EV kryté z baterie (noc, drahý buy, plná baterie) → via_bat > 0 a + # battery_arbitrage_czk nese oportunitní cenu (ne konstantní 0) + bat = _battery() + slots = [_slot(_BASE, i, buy=8.0, sell=1.0, ev1=True, load=300) for i in range(8)] + session = SimpleNamespace( + target_deadline=_BASE + timedelta(hours=2), + energy_needed_wh=6000.0, + headroom_wh=0.0, + opportunistic_value_czk_kwh=0.0, + ) + results, _, _ = _solve( + slots, battery=bat, soc0=bat.soc_max_wh, ev_sessions=(session, None) + ) + via = sum(r.ev1_via_bat_w for r in results) + self.assertGreater(via, 0, "drahý buy + plná baterie → EV z baterie") + arb = sum(r.battery_arbitrage_czk for r in results) + self.assertGreater(arb, 0.0, "via_bat sloty musí reportovat oportunitní Kč") + for r in results: + if r.ev1_via_bat_w == 0: + self.assertEqual(r.battery_arbitrage_czk, 0.0) + + class EvDeadlineTests(unittest.TestCase): def test_ev_energy_delivered_before_deadline(self) -> None: slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)] diff --git a/db/migration/V099__ev_session_opportunistic.sql b/db/migration/V099__ev_session_opportunistic.sql new file mode 100644 index 0000000..ccdeeb3 --- /dev/null +++ b/db/migration/V099__ev_session_opportunistic.sql @@ -0,0 +1,16 @@ +-- Per-session override oportunistického EV nabíjení (V094 zavedl hodnotu +-- na asset_vehicle). NULL = zdědit z vozidla; 0 = oportunismus pro tuto +-- session vypnut („nenabíjet nad target"); > 0 = vlastní ocenění kWh. +-- Efektivní hodnota se skládá v ems.fn_planning_site_context +-- (coalesce(session, vehicle)); patch přes ems.fn_ev_session_apply_patch. + +alter table ems.ev_session + add column if not exists opportunistic_value_czk_kwh numeric(6, 3) null; + +comment on column ems.ev_session.opportunistic_value_czk_kwh is +'Per-session override hodnoty kWh nabité NAD target (Kč/kWh). NULL = zdědit z asset_vehicle.opportunistic_value_czk_kwh; 0 = oportunistické nabíjení pro tuto session vypnuto (headroom_wh = 0 v plánovacím kontextu).'; + +-- v2 reporting: battery_arbitrage_czk nese oportunitní hodnotu kWh z baterie +-- do EV (via_bat × oportunitní cena), ne konstantní 0 / v1 marži exportu. +comment on column ems.planning_interval.battery_arbitrage_czk is +'Ekonomika baterie mimo slotový cashflow (Kč). v1: marže exportu baterie ge_bat × (sell − acquisition) × h. v2: oportunitní cena EV energie z baterie — ev_via_bat × (nejnižší sell exportního slotu téhož pražského dne, jinak terminal value); slotový buy pro tyto kWh neplatí.'; diff --git a/db/routines/R__015_fn_ev_session_patch.sql b/db/routines/R__015_fn_ev_session_patch.sql index 4021479..107fae8 100644 --- a/db/routines/R__015_fn_ev_session_patch.sql +++ b/db/routines/R__015_fn_ev_session_patch.sql @@ -11,10 +11,19 @@ declare begin if not (p_patch ? 'target_soc_pct') and not (p_patch ? 'target_deadline') - and not (p_patch ? 'soc_at_connect_pct') then + and not (p_patch ? 'soc_at_connect_pct') + and not (p_patch ? 'opportunistic_value_czk_kwh') then return jsonb_build_object('success', false, 'error', 'no_fields'); end if; + if (p_patch ? 'opportunistic_value_czk_kwh') + and jsonb_typeof(p_patch->'opportunistic_value_czk_kwh') <> 'null' + and (p_patch->>'opportunistic_value_czk_kwh')::numeric < 0 then + return jsonb_build_object( + 'success', false, 'error', 'opportunistic_value_negative' + ); + end if; + update ems.ev_session es set target_soc_pct = case @@ -44,6 +53,16 @@ begin else (p_patch->>'target_deadline')::timestamptz end else es.target_deadline + end, + -- NULL = zdědit z asset_vehicle; 0 = oportunismus pro session vypnut + opportunistic_value_czk_kwh = case + when p_patch ? 'opportunistic_value_czk_kwh' then + case + when p_patch->'opportunistic_value_czk_kwh' is null + or jsonb_typeof(p_patch->'opportunistic_value_czk_kwh') = 'null' then null + else (p_patch->>'opportunistic_value_czk_kwh')::numeric + end + else es.opportunistic_value_czk_kwh end where es.id = p_session_id and es.site_id = p_site_id @@ -57,5 +76,5 @@ begin end; $fn$; -comment on function ems.fn_ev_session_apply_patch(int, int, jsonb) is - 'PATCH EV session – jen klíče přítomné v JSON (ISO string pro deadline; soc_at_connect_pct z Tesla API).'; +comment on function ems.fn_ev_session_apply_patch is + 'PATCH EV session – jen klíče přítomné v JSON (ISO string pro deadline; soc_at_connect_pct z Tesla API; opportunistic_value_czk_kwh >= 0, NULL = zdědit z vozidla, 0 = vypnout).'; diff --git a/db/routines/R__033_fn_plan_current_bundle.sql b/db/routines/R__033_fn_plan_current_bundle.sql index 7a157aa..437cd89 100644 --- a/db/routines/R__033_fn_plan_current_bundle.sql +++ b/db/routines/R__033_fn_plan_current_bundle.sql @@ -85,6 +85,8 @@ begin 'deye_gen_cutoff_enabled', pi.deye_gen_cutoff_enabled, 'ev1_setpoint_w', pi.ev1_setpoint_w, 'ev2_setpoint_w', pi.ev2_setpoint_w, + 'ev1_via_bat_w', pi.ev1_via_bat_w, + 'ev2_via_bat_w', pi.ev2_via_bat_w, 'heat_pump_enabled', pi.heat_pump_enabled, 'pv_a_curtailed_w', pi.pv_a_curtailed_w, 'expected_cost_czk', pi.expected_cost_czk, @@ -123,6 +125,8 @@ begin 'deye_gen_cutoff_enabled', null, 'ev1_setpoint_w', null, 'ev2_setpoint_w', null, + 'ev1_via_bat_w', null, + 'ev2_via_bat_w', null, 'heat_pump_enabled', null, 'pv_a_curtailed_w', null, 'expected_cost_czk', null, @@ -248,5 +252,5 @@ begin end; $fn$; -comment on function ems.fn_plan_current_bundle(int) is - 'Aktivní planning_run + intervaly + souhrn (GET /plan/current). PV za horizont plánu z canonical forecast; delta profil z cache.'; +comment on function ems.fn_plan_current_bundle is + 'Aktivní planning_run + intervaly + souhrn (GET /plan/current). PV za horizont plánu z canonical forecast; delta profil z cache; intervals nesou ev1/ev2_via_bat_w (EV energie z baterie — UI nemá cenit slotovým buy).'; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index 3dfed86..f42e0ae 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -165,6 +165,7 @@ begin jsonb_agg( jsonb_build_object( 'max_charge_power_w', v.max_charge_power_w, + 'min_power_w', coalesce(ch.min_power_w, 0), 'battery_capacity_kwh', v.battery_capacity_kwh, 'default_target_soc_pct', v.default_target_soc_pct ) @@ -193,8 +194,11 @@ begin - coalesce(es.energy_delivered_wh, 0)::numeric ) <= 0 and ( - coalesce(v.opportunistic_value_czk_kwh, 0) <= 0 - or (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct)) <= 0 + coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0 + or (100 - greatest( + coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, + es.soc_at_connect_pct::numeric + )) <= 0 ) then null::jsonb else jsonb_build_object( 'target_deadline', es.target_deadline, @@ -205,15 +209,20 @@ begin * (v.battery_capacity_kwh * 1000) - coalesce(es.energy_delivered_wh, 0)::numeric ), + -- headroom od max(target, SoC při připojení): „nenabíjet" (nízký + -- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může + -- fyzicky vzít jen energii nad svým aktuálním SoC. 'headroom_wh', case - when coalesce(v.opportunistic_value_czk_kwh, 0) > 0 then greatest( + when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest( 0, - (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct))::numeric - / 100.0 * (v.battery_capacity_kwh * 1000) + (100 - greatest( + coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, + es.soc_at_connect_pct::numeric + )) / 100.0 * (v.battery_capacity_kwh * 1000) ) else 0 end, - 'opportunistic_value_czk_kwh', coalesce(v.opportunistic_value_czk_kwh, 0) + 'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) ) end from ems.ev_session es @@ -238,8 +247,11 @@ begin - coalesce(es.energy_delivered_wh, 0)::numeric ) <= 0 and ( - coalesce(v.opportunistic_value_czk_kwh, 0) <= 0 - or (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct)) <= 0 + coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0 + or (100 - greatest( + coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, + es.soc_at_connect_pct::numeric + )) <= 0 ) then null::jsonb else jsonb_build_object( 'target_deadline', es.target_deadline, @@ -250,15 +262,20 @@ begin * (v.battery_capacity_kwh * 1000) - coalesce(es.energy_delivered_wh, 0)::numeric ), + -- headroom od max(target, SoC při připojení): „nenabíjet" (nízký + -- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může + -- fyzicky vzít jen energii nad svým aktuálním SoC. 'headroom_wh', case - when coalesce(v.opportunistic_value_czk_kwh, 0) > 0 then greatest( + when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest( 0, - (100 - coalesce(es.target_soc_pct, v.default_target_soc_pct))::numeric - / 100.0 * (v.battery_capacity_kwh * 1000) + (100 - greatest( + coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, + es.soc_at_connect_pct::numeric + )) / 100.0 * (v.battery_capacity_kwh * 1000) ) else 0 end, - 'opportunistic_value_czk_kwh', coalesce(v.opportunistic_value_czk_kwh, 0) + 'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) ) end from ems.ev_session es @@ -333,5 +350,5 @@ begin end; $fn$; -comment on function ems.fn_planning_site_context(int) is - 'Kontext pro planning_engine / LP (bez samotného solveru).'; +comment on function ems.fn_planning_site_context is + 'Kontext pro planning_engine / LP (bez samotného solveru). EV session: opportunistic_value = coalesce(session, vehicle); headroom_wh od max(target, soc_at_connect), 0 při vypnutém oportunismu; vehicles nesou min_power_w wallboxu.'; diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index c38dcfc..3215b4d 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -376,12 +376,33 @@ stav baterie auta → cíl (+kWh), deadline, plánovaná nabíjecí okna s ø ce Tvrdý cíl (deadline) = „bez tohohle neodjedu"; měkký cíl = „klidně doplň do 100 %, když je energie skoro zadarmo". Implementace: dekompozice -Σ(EV energie) == needed − unmet + opp; `opp ∈ [0, headroom]` -(headroom = (100 − target) % kapacity, jen když `asset_vehicle. -opportunistic_value_czk_kwh > 0`; default 1 Kč/kWh, 0 = vypnuto). +Σ(EV energie) == needed − unmet + opp; `opp ∈ [0, headroom]`. +**Headroom = (100 − max(target, soc_at_connect)) % kapacity** (fix paradoxu +„nižší target → větší headroom": auto fyzicky bere jen energii nad svým +aktuálním SoC). **Hodnota kWh:** `coalesce(ev_session.opportunistic_value_czk_kwh, +asset_vehicle.opportunistic_value_czk_kwh)` — V099 přidal per-session override +(NULL = zdědit z vozidla, 0 = vypnout pro session ⇒ headroom_wh = 0; patch +klíčem `opportunistic_value_czk_kwh` ve `fn_ev_session_apply_patch`, validace ≥ 0). +Default vozidla 1 Kč/kWh, 0 = vypnuto. Hodnota = ušetřené BUDOUCÍ nabíjení (auto neumí zpět — žádný noční prodej), proto nízká → uplatní se při záporných cenách / plné domácí baterce (lepší než curtail), běžné ceny ji nezaplatí. Víkendový vzor „pátek nemusím do plna, víkend doplní zadarmo" z toho plyne sám. Dekompozice -zároveň stropuje celkovou energii do auta (dřív při buy<0 chyběl strop). -Session zůstává v plánu i po dosažení targetu, dokud má headroom. +zároveň stropuje celkovou energii do auta (dřív při buy<0 chyběl strop) — +a **bez session je EV == 0** (stop-session nevypíná jen tvrdý cíl, ale i +oportunismus). Session zůstává v plánu i po dosažení targetu, dokud má headroom; +**oportunistická vrstva není omezená deadline** (auto bývá doma dál, odjezd +řeší rolling replan — rozhodnutí 2026-06-12). + +### Min. výkon wallboxu a účtování via-bat (2026-06-12, dev) + +- **`asset_ev_charger.min_power_w`** (1380 W = 6 A IEC 61851) jde přes + `fn_planning_site_context` do solver_v2: binárka `ev_on[e][t]`, + `setpoint ∈ {0} ∪ [min_power_w, max]` — žádné nevykonatelné 400–900 W. +- **Tvrdý cíl** sčítá jen sloty **před** deadline (slot začínající v deadline + už nepatří „do deadline" — oprava off-by-one). +- **`ev_direct ≤ gi + PV`** (fyzikální split; via_bat kryje vybíjení baterie). +- **Reporting:** kWh do EV z baterie (via_bat) neplatí slotový buy; solver_v2 + je oceňuje oportunitní cenou v `planning_interval.battery_arbitrage_czk` + (min sell exportního slotu téhož pražského dne, jinak terminal value) a + `fn_plan_current_bundle.intervals` nese `ev1/ev2_via_bat_w` pro UI. diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 5a1efa3..68117f9 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -819,4 +819,30 @@ Plánovač má dvě implementace, přepínané env proměnnými (`backend/app/co front-load v sell<0 (`planner_pv_risk_frontload_czk_kwh`, V090), denní SoC rampa (`safety_soc_target_wh` × `planner_safety_soc_risk_factor`, V091). Detail: hlavička `solver_v2.py` + changelog 2026-06-12. +- **EV ve v2 — účtování, headroom, min. výkon (2026-06-12):** + - **Deadline boundary:** tvrdý cíl sčítá energii jen ve slotech `t < t_deadline` + (první slot s `interval_start >= deadline` už energii dodává po odjezdu — + dřívější off-by-one `range(t_dl + 1)` opraven). + - **Měkký cíl (oportunismus):** dekompozice `Σ(EV energie) == needed − unmet + opp`; + `opp ∈ [0, headroom_wh]`. **Headroom** = `(100 − max(target_soc_pct, + soc_at_connect_pct)) % kapacity` (R__039) — „nenabíjet" (nízký target) už + paradoxně NEzvětšuje oportunistickou vrstvu; auto fyzicky bere jen nad svým + SoC. Hodnota kWh = `coalesce(ev_session.opportunistic_value_czk_kwh, + asset_vehicle.opportunistic_value_czk_kwh)` (V099: session override; NULL = + zdědit, 0 = vypnout pro session → `headroom_wh = 0`); patch klíčem + `opportunistic_value_czk_kwh` v `fn_ev_session_apply_patch` (validace ≥ 0). + **Oportunistická vrstva NENÍ omezená deadline** (rozhodnutí 2026-06-12: + auto bývá doma dál, odjezd řeší rolling replan). **Bez session je EV == 0** + i při `buy < 0` (stop-session; dřív neomezené „pumpování"). + - **Min. výkon wallboxu:** `asset_ev_charger.min_power_w` (6 A ≈ 1380 W) jde + přes `fn_planning_site_context` (vehicles JSON) do binárky `ev_on[e][t]` — + setpoint ∈ {0} ∪ [min, max]; konec nevykonatelných 400–900 W setpointů. + - **Fyzikální split:** `Σ_e ev_direct[e][t] ≤ gi[t] + pv_a_net + pv_b_eff` + (direct jen ze sítě + PV; via_bat kryje `bd`). Ekonomiku nemění, ale split + direct/via_bat už není arbitrární. + - **Reporting via_bat:** kWh do EV z baterie neplatí slotový buy — solver_v2 + plní `battery_arbitrage_czk` oportunitní cenou (min `sell` exportního slotu + téhož pražského dne, jinak terminal value; clamp ≥ 0) × via_bat energie + (dřív konstantní 0). `fn_plan_current_bundle` nese `ev1/ev2_via_bat_w` + v `intervals`, aby UI necenilo EV kWh z baterie slotovým buy. - Regresní brána a měření: `scripts/harness/README.md` (golden replay, economics report, penalty audit, `solver_v2_eval.py`); plán refaktoru: `docs/refactor-clean-planner.md`. diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 0ba85b6..c14e1e7 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,45 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-12 — EV účtování v2: headroom, deadline boundary, min. výkon WB, via-bat reporting + +**Problém (hloubková diagnóza EV):** (a) „nenabíjet" (nízký target) oportunismus +nevypnul a paradoxně ZVĚTŠIL headroom (= 100 − target); session bez mandátu +(nebo žádná session) při `buy < 0` pumpovala energii bez stropu; (b) off-by-one +v deadline sumě (`range(t_dl + 1)` — slot začínající v deadline se počítal „do +deadline"); (c) reporting lhal: `battery_arbitrage_czk` konstantně 0, via_bat se +nepropagoval do bundle, UI cenilo EV kWh z baterie slotovým buy; (d) split +ev_direct/via_bat byl arbitrární (direct nesvázán s gi + PV); (e) min. výkon +wallboxu (`asset_ev_charger.min_power_w`, 1380 W = 6 A) ignorován → setpointy +400–900 W nevykonatelné. + +**Mechanismus:** headroom z `max(target_soc_pct, soc_at_connect_pct)` a +opportunistic_value = `coalesce(session, vehicle)` v `fn_planning_site_context` +(V099: `ev_session.opportunistic_value_czk_kwh`, NULL = zdědit, 0 = vypnout; +patch přes `fn_ev_session_apply_patch`, validace ≥ 0); solver_v2: deadline suma +`range(t_dl)`, bez session EV == 0, dekompozice `total == needed − unmet + opp` +i pro needed = 0; binárka `ev_on` → setpoint ∈ {0} ∪ [min_power_w, max] +(min_power_w nově ve vehicles JSON kontextu); `Σ ev_direct ≤ gi + pv_a_net + +pv_b_eff`; `battery_arbitrage_czk` = via_bat kWh × oportunitní cena (min sell +exportního slotu téhož pražského dne, jinak terminal value, clamp ≥ 0); +`fn_plan_current_bundle.intervals` + `ev1/ev2_via_bat_w`. **Oportunismus PO +deadline zůstává POVOLENÝ** (rozhodnutí: auto často doma, odjezd řeší rolling +replan). Fixtures: `extract_fixtures.py --keep-ev` (default dál EV nuluje). + +**Soubory:** `V099__ev_session_opportunistic.sql`, `R__039_fn_planning_site_context.sql`, +`R__015_fn_ev_session_patch.sql`, `R__033_fn_plan_current_bundle.sql`, +`services/planning/solver_v2.py`, `services/planning/db_io.py`, +`scripts/harness/extract_fixtures.py` + README, `docs/04-modules/ev-charging.md`, +`docs/04-modules/planning.md`. + +**Ověření:** `tests/test_solver_v2.py` +7 (deadline boundary, stop-session, +no-session při buy<0, direct ≤ gi+pv, setpoint ∈ {0}∪[1380, max], opp po +deadline > 0, battery_arbitrage_czk > 0 u via_bat); golden gate beze změny +snapshotů (v1 nedotčen, fixtures bez EV); `solver_v2_eval.py` před/po identický +(CELKEM −1283.5 Kč, Δ −221.9 vs v1); plná sada 310 passed / 4 xfailed. + +--- + ## 2026-06-12 — idle-skip telemetrie: TUV delta normalizovaná na °C/min **Problém:** telemetry_collector nově přeskakuje 1min zápisy idle zařízení diff --git a/scripts/harness/README.md b/scripts/harness/README.md index b41322c..86de647 100644 --- a/scripts/harness/README.md +++ b/scripts/harness/README.md @@ -45,6 +45,11 @@ python3 scripts/harness/extract_fixtures.py --site-code home-01 --day 2026-06-07 cd backend && GOLDEN_UPDATE=1 python3 -m pytest tests/test_golden_replay.py -q ``` +`--keep-ev` zmrazí do fixture i otevřené EV sessions z doby extrakce (default je +vynulovat — historické okno bez session). Hodí se pro EV scénáře (deadline, +měkký cíl / oportunistické nabíjení, min. výkon wallboxu); `meta.keep_ev` +ve fixture říká, jak byla pořízena. + Pokryté scénáře (v4 fixtures): home-01 hluboký neg-sell (sell −1.57, buy −0.89), home-01 běžný spot den, BA81 běžný den, KV1 fixní nákup. Při změnách heuristik přidávej scénář, který změnu pokrývá. diff --git a/scripts/harness/extract_fixtures.py b/scripts/harness/extract_fixtures.py index 1035309..62975d5 100644 --- a/scripts/harness/extract_fixtures.py +++ b/scripts/harness/extract_fixtures.py @@ -154,10 +154,12 @@ async def extract(args: argparse.Namespace) -> Path: # Determinismus replay: # - SoC/TUV fixujeme do contextu (přepis aktuálních hodnot historickými / extrakčními), - # - otevřené EV sessions z doby extrakce nepatří k historickému oknu → vynulovat, + # - otevřené EV sessions z doby extrakce nepatří k historickému oknu → default + # vynulovat; --keep-ev je zmrazí do fixture (EV scénáře: deadline, měkký cíl), # - operating_mode fixně AUTO (plný solver, srovnatelnost napříč fixtures). ctx["soc_wh"] = soc_wh - ctx["ev_sessions"] = [] + if not args.keep_ev: + ctx["ev_sessions"] = [] ctx["operating_mode"] = "AUTO" fixture = { @@ -172,12 +174,18 @@ async def extract(args: argparse.Namespace) -> Path: "soc_wh": round(soc_wh, 1), "soc_source": soc_source, "tag": args.tag, + "keep_ev": bool(args.keep_ev), "extracted_at": datetime.now(tz=PRAGUE).isoformat(), "dsn_host": dsn.split("@")[-1].split("/")[0] if "@" in dsn else "?", "note": ( "Vstupy plánovače zmrazené k okamžiku extrakce (context = aktuální konfigurace, " "sloty = fn_load_planning_slots_full nad historickými cenami/forecasty). " - "EV sessions vynulovány, operating_mode=AUTO." + + ( + "EV sessions zmrazeny (--keep-ev)" + if args.keep_ev + else "EV sessions vynulovány" + ) + + ", operating_mode=AUTO." ), }, "context_json": ctx, @@ -209,6 +217,11 @@ def main() -> None: p.add_argument("--day", required=True, help="Pražský den YYYY-MM-DD (začátek okna 00:00)") p.add_argument("--hours", type=int, default=36, help="Délka okna v hodinách (default 36)") p.add_argument("--tag", required=True, help="Krátký popis scénáře (neg_sell_deep, normal, …)") + p.add_argument( + "--keep-ev", + action="store_true", + help="Zachovat otevřené EV sessions v contextu (default: vynulovat — historické okno)", + ) p.add_argument("--dsn", default=None, help="postgresql:// DSN (jinak EMS_DB_DSN / DB_* env)") p.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR), help="Cílový adresář fixtures") args = p.parse_args()