diff --git a/backend/services/planning/constants.py b/backend/services/planning/constants.py index 058b9e3..5a31a24 100644 --- a/backend/services/planning/constants.py +++ b/backend/services/planning/constants.py @@ -116,3 +116,13 @@ _PRAGUE_TZ = ZoneInfo("Europe/Prague") # --- Konstanty původně roztroušené mezi funkcemi planning_engine.py (Fáze 1) --- MORNING_PRENEG_START_HOUR = 5 MORNING_PRENEG_END_HOUR = 11 + +# --- EV anti-fragmentace (Fix B, solver_v2) --- +# IEC 61851 min. nabíjecí proud (A) na fázi. 3f wallbox NEumí jet 1f trickle pod +# 6 A na všech fázích → fyzikální dolní mez dávky je 6 A × phases × napětí. +EV_MIN_CHARGE_CURRENT_A = 6.0 +# Síťové napětí fáze (V) pro odhad 3f power floor (3f wallbox: 6 A × 3 × 230 ≈ 4140 W). +EV_PHASE_VOLTAGE_V = 230.0 +# Práh, od kolika fází považujeme wallbox za vícefázový (≥ tato hodnota → power floor +# z fází; jinak držíme min_power_w z DB). 3 = jen čistě 3f wallbox dostane 3f floor. +EV_MULTIPHASE_FLOOR_MIN_PHASES = 3 diff --git a/backend/services/planning/db_io.py b/backend/services/planning/db_io.py index 5e5fde5..2e15474 100644 --- a/backend/services/planning/db_io.py +++ b/backend/services/planning/db_io.py @@ -141,6 +141,13 @@ async def _load_site_context(site_id: int, db): SimpleNamespace( max_charge_power_w=int(v["max_charge_power_w"]), min_power_w=int(v.get("min_power_w") or 0), + # phases / planner_ev_start_penalty_czk: parametry wallboxu pro + # anti-fragmentaci EV v solver_v2 (Fix B). Default phases=3 (typický + # 3f wallbox), start penalta 0 = no-op (golden-safe). + phases=int(v.get("phases") or 3), + planner_ev_start_penalty_czk=float( + v.get("planner_ev_start_penalty_czk") or 0.0 + ), battery_capacity_kwh=float(v["battery_capacity_kwh"]), default_target_soc_pct=float(v["default_target_soc_pct"]), ) @@ -150,6 +157,8 @@ async def _load_site_context(site_id: int, db): SimpleNamespace( max_charge_power_w=0, min_power_w=0, + phases=3, + planner_ev_start_penalty_czk=0.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 0afa45f..01a2eb6 100644 --- a/backend/services/planning/solver_v2.py +++ b/backend/services/planning/solver_v2.py @@ -38,7 +38,15 @@ # 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í. +# jinak terminal value) — slotový buy pro ně neplatí. U TŘÍFÁZOVÉHO wallboxu +# (asset_ev_charger.phases ≥ 3) je floor zvednut na 6 A × fáze × 230 V (≈ 4140 +# W pro 3f) místo 1f ~1380 W → ruší sub-6A 1f trickle drobky (cap = max výkon +# vozidla). Fáze/min jdou z DB přes vehicle kontext (R__039). +# - anti-fragmentace EV (Fix B): per-slot binárka ev_on (vždy při floor NEBO +# start penaltě) + hrana ev_start[t] ≥ ev_on[t] − ev_on[t−1]; objektiv += +# Σ ev_start × asset_ev_charger.planner_ev_start_penalty_czk (Kč). Drobná +# penalta (filozofie v2: nejistota/opotřebení = cena, ne tvrdá priorita) → +# souvislá dávka místo rozsekání. Default 0 = no-op (golden-safe). # - 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 @@ -60,6 +68,9 @@ from typing import Any, Optional import pulp from services.planning.constants import ( + EV_MIN_CHARGE_CURRENT_A, + EV_MULTIPHASE_FLOOR_MIN_PHASES, + EV_PHASE_VOLTAGE_V, INTERVAL_H, SOLVER_TIME_LIMIT, ) @@ -175,9 +186,52 @@ 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) + ev_start_terms: list = [] # (ev_start var, penalta Kč) — anti-fragmentace (Fix B) + + def _ev_min_power_w(e: int) -> float: + """Dolní mez nabíjecí dávky (W): u 3f wallboxu fyzikální 6 A × fáze × napětí + (≈ 4140 W) místo 1f ~1380 W → zruší sub-6A 1f trickle. Stropuje se max + výkonem vozidla (jinak by připojený slot byl infeasible). Bez spolehlivého + počtu fází padá zpět na min_power_w z DB.""" + veh = vehicles[e] + base_min = max(0.0, float(getattr(veh, "min_power_w", 0) or 0)) + phases = int(getattr(veh, "phases", 0) or 0) + ev_max = float(veh.max_charge_power_w) + if phases >= EV_MULTIPHASE_FLOOR_MIN_PHASES: + floor = EV_MIN_CHARGE_CURRENT_A * phases * EV_PHASE_VOLTAGE_V + base_min = max(base_min, floor) + # strop max výkonem vozidla — floor nesmí překročit, co auto/wallbox umí + if ev_max > 0: + base_min = min(base_min, ev_max) + return base_min + + def _ev_start_penalty_czk(e: int) -> float: + return max(0.0, float(getattr(vehicles[e], "planner_ev_start_penalty_czk", 0.0) or 0.0)) + + ev_min_w = [_ev_min_power_w(e) for e in range(EV)] + ev_start_pen = [_ev_start_penalty_czk(e) for e in range(EV)] + # ev_on[e][t]: zapnutost wallboxu v slotu. Vždy potřeba, pokud platí min-power + # floor (gate) NEBO start penalta (anti-fragmentace). ev_start[e][t]: náběžná + # hrana ev_on (start nové dávky) — jen když je start penalta > 0 (jinak žádný + # extra MILP balast a default 0 = no-op, golden-safe). + ev_needs_on = [(ev_min_w[e] > 0.0) or (ev_start_pen[e] > 0.0) for e in range(EV)] + ev_on = [ + [ + pulp.LpVariable(f"evon_{e}_{t}", cat=pulp.LpBinary) + for t in range(T) + ] + if ev_needs_on[e] + else None + for e in range(EV) + ] + ev_start = [ + [ + pulp.LpVariable(f"evstart_{e}_{t}", 0, 1) + for t in range(T) + ] + if ev_start_pen[e] > 0.0 + else None + 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) @@ -263,20 +317,30 @@ 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 + min. výkon wallboxu (binárka jen kde je min > 0) + # EV dostupnost + min. výkon wallboxu (binárka ev_on) + start hrana. + # ev_on existuje, když platí min-power floor NEBO start penalta. for e in range(EV): + on_t = ev_on[e][t] if ev_on[e] is not None else None if not _connected(e, t): prob += ev_direct[e][t] == 0 prob += ev_via_bat[e][t] == 0 + if on_t is not None: + prob += on_t == 0, f"ev_off_{e}_{t}" else: 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}" + if on_t is not None and ev_max_w > 0: + # on=1 nutné kdykoli ev_total > 0 (start penalta i floor to potřebují) + prob += ev_total <= ev_max_w * on_t, f"ev_max_{e}_{t}" + if 0 < ev_min_w[e] <= ev_max_w: + prob += ev_total >= ev_min_w[e] * on_t, f"ev_min_{e}_{t}" else: prob += ev_total <= ev_max_w + # start = náběžná hrana ev_on (≥ on[t] − on[t−1]); slot 0 startuje vždy, + # když je on (žádný předchozí stav v horizontu). + if ev_start[e] is not None and on_t is not None: + prev_on = ev_on[e][t - 1] if t > 0 else 0 + prob += ev_start[e][t] >= on_t - prev_on, f"ev_start_{e}_{t}" # provozní režimy (tvrdé constraints dle operating-modes.md) if om == "SELF_SUSTAIN": @@ -379,6 +443,15 @@ def solve_dispatch_v2( extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet) if ev_opp: extras -= pulp.lpSum(o / 1000.0 * val for o, val in ev_opp if val > 0) + # anti-fragmentace EV (Fix B): Σ ev_start × start_penalta (Kč). Default 0 → no-op. + ev_start_terms = [ + ev_start[e][t] * ev_start_pen[e] + for e in range(EV) + if ev_start[e] is not None and ev_start_pen[e] > 0.0 + for t in range(T) + ] + if ev_start_terms: + extras += pulp.lpSum(ev_start_terms) nb_terms = [ nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price)) for t in range(T) @@ -521,6 +594,8 @@ def solve_dispatch_v2( "slot_count": T, "ev_sessions": sum(1 for x in ev_sessions if x is not None), "ev_min_power_w": ev_min_w, + "ev_phases": [int(getattr(vehicles[e], "phases", 0) or 0) for e in range(EV)], + "ev_start_penalty_czk": ev_start_pen, "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, @@ -535,6 +610,12 @@ def solve_dispatch_v2( "terminal_value_czk": round(terminal * _val(soc[T - 1]), 3), "ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet], "ev_opp_wh": [round(_val(o), 1) for o, _v in ev_opp], + "ev_starts": [ + int(round(sum(_val(ev_start[e][t]) for t in range(T)))) + if ev_start[e] is not None + else 0 + for e in range(EV) + ], }, "solver_duration_ms": duration_ms, "solver_status": status_str, diff --git a/db/migration/V108__asset_ev_charger_start_penalty.sql b/db/migration/V108__asset_ev_charger_start_penalty.sql new file mode 100644 index 0000000..0224a0a --- /dev/null +++ b/db/migration/V108__asset_ev_charger_start_penalty.sql @@ -0,0 +1,11 @@ +-- EV anti-fragmentace (Fix B): per-wallbox cena za START nabíjecí dávky. +-- solver_v2 zavádí per-slot binárku ev_on a hranu ev_start[t] >= ev_on[t] - ev_on[t-1]; +-- do objektivu přidá Σ ev_start × tato cena. Drobná penalta (filozofie v2: nejistota / +-- opotřebení = cena) tlačí solver k SOUVISLÉ dávce místo rozsekaného nabíjení přes +-- nesouvislé sloty. Default 0 = no-op (golden gate beze změny); kalibruje se per site. + +alter table ems.asset_ev_charger + add column if not exists planner_ev_start_penalty_czk numeric(6, 3) not null default 0; + +comment on column ems.asset_ev_charger.planner_ev_start_penalty_czk is + 'Cena (Kč) za START nabíjecí dávky v solver_v2: do objektivu jde Σ ev_start × tato hodnota (ev_start = náběžná hrana ev_on mezi sloty). Drobná penalta proti fragmentaci nabíjení (rozsekané nesouvislé sloty) — souvislá dávka na 3f místo scattered 1f trickle. 0 = vypnuto (no-op, golden-safe). Kalibruje se per wallbox.'; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index 07f0561..86373da 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -162,11 +162,17 @@ begin ) ); + -- vehicles nesou parametry SVÉHO wallboxu (join přes default_charger_id, + -- výběr DYNAMICKY podle site_id + id, NE podle kódu): min_power_w, počet fází + -- (phases — solver_v2 z něj odvozuje 3f power floor proti 1f trickle) a + -- planner_ev_start_penalty_czk (anti-fragmentace nabíjení, Fix B; default 0 = no-op). select coalesce( jsonb_agg( jsonb_build_object( 'max_charge_power_w', v.max_charge_power_w, 'min_power_w', coalesce(ch.min_power_w, 0), + 'phases', coalesce(ch.phases, 3), + 'planner_ev_start_penalty_czk', coalesce(ch.planner_ev_start_penalty_czk, 0), 'battery_capacity_kwh', v.battery_capacity_kwh, 'default_target_soc_pct', v.default_target_soc_pct ) @@ -259,4 +265,4 @@ end; $fn$; comment on function ems.fn_planning_site_context is - 'Kontext pro planning_engine / LP (bez samotného solveru). EV session přes fn_ev_session_planning_json: session se nevyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunismu i jako známá zátěž; 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.'; + 'Kontext pro planning_engine / LP (bez samotného solveru). EV session přes fn_ev_session_planning_json: session se nevyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunismu i jako známá zátěž; opportunistic_value = coalesce(session, vehicle); headroom_wh od max(target, soc_at_connect), 0 při vypnutém oportunismu; vehicles nesou parametry svého wallboxu (min_power_w, phases, planner_ev_start_penalty_czk — anti-fragmentace EV v solver_v2, default 0 = no-op).'; diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 18e5bb6..e1e8138 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -5,6 +5,13 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen --- +## 2026-06-14 — EV anti-fragmentace + 3f power floor (Fix B, solver_v2) + +- **Problém:** EV nabíjení v solveru spojité po slotech bez start/stop penalty → rozsekané přes nesouvislé sloty + dílčí 1f trickle (sub-6A, který control stejně shazoval na 0 A) → cyklování nabíječky, Tesla notifikace. +- **Mechanismus (fix):** (a) **3f power floor** — pro `asset_ev_charger.phases >= 3` je min nabíjecí dávka 6 A × fáze × 230 V (≈4140 W) místo 1f ~1380 W (strop = max výkon vozidla); ruší sub-6A 1f drobky (fyzikálně realizovatelné dávky). (b) **block-start penalta** — per-slot binárka `ev_on`, hrana `ev_start[t] >= ev_on[t]−ev_on[t−1]`, objektiv += Σ ev_start × `asset_ev_charger.planner_ev_start_penalty_czk` (V108, **default 0 = no-op**, kalibruje se per wallbox). Drží v2 filozofii „nejistota/opotřebení = cena". +- **Soubory:** V108, R__039 (phases + start_penalty do kontextu), db_io.py, constants.py, solver_v2.py. +- **Ověření:** golden gate 7 passed + full suite 363 passed (fixtures EV nulují → start penalta inertní). Živě ověřeno: `asset_ev_charger.phases=3`, `min_power_w=1380` (1f) → 3f floor opraví na 4140. **Pozn.:** 3f floor je AKTIVNÍ v prod (ne za flagem) — korektnostní fix; start penalta default-off do kalibrace. Postaveno paralelním worktree agentem, integrováno + zvalidováno sériově. + ## 2026-06-14 — EV: tolerance „dost dobré" — konec honění posledních % do 100 % - **Problém:** po live-SoC fixu zůstalo malé deadline dobití (~1.33 kWh v 05:00) honící posledních ~2 % k targetu 100 %. live_soc clampnuté na 99 % vs target 100 % → needed_wh nikdy neklesne na 0 → **věčné mini-dobíjení = start/stop nabíječky, Tesla notifikace, zbytečné Modbus zápisy** (cyklování).