feat(planner): EV anti-fragmentace + 3f power floor (Fix B)

3f floor (phases>=3 → 6A×fáze×230 ≈4140W, ruší 1f trickle) + block-start penalta
(asset_ev_charger.planner_ev_start_penalty_czk V108, default 0=no-op). Golden gate
zelená (363 passed). Postaveno paralelním worktree agentem, zvalidováno sériově.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-14 22:55:17 +02:00
parent fd7012e23d
commit a32839bf67
6 changed files with 134 additions and 10 deletions

View File

@@ -116,3 +116,13 @@ _PRAGUE_TZ = ZoneInfo("Europe/Prague")
# --- Konstanty původně roztroušené mezi funkcemi planning_engine.py (Fáze 1) --- # --- Konstanty původně roztroušené mezi funkcemi planning_engine.py (Fáze 1) ---
MORNING_PRENEG_START_HOUR = 5 MORNING_PRENEG_START_HOUR = 5
MORNING_PRENEG_END_HOUR = 11 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

View File

@@ -141,6 +141,13 @@ async def _load_site_context(site_id: int, db):
SimpleNamespace( SimpleNamespace(
max_charge_power_w=int(v["max_charge_power_w"]), max_charge_power_w=int(v["max_charge_power_w"]),
min_power_w=int(v.get("min_power_w") or 0), 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"]), battery_capacity_kwh=float(v["battery_capacity_kwh"]),
default_target_soc_pct=float(v["default_target_soc_pct"]), default_target_soc_pct=float(v["default_target_soc_pct"]),
) )
@@ -150,6 +157,8 @@ async def _load_site_context(site_id: int, db):
SimpleNamespace( SimpleNamespace(
max_charge_power_w=0, max_charge_power_w=0,
min_power_w=0, min_power_w=0,
phases=3,
planner_ev_start_penalty_czk=0.0,
battery_capacity_kwh=1.0, battery_capacity_kwh=1.0,
default_target_soc_pct=80.0, default_target_soc_pct=80.0,
) )

View File

@@ -38,7 +38,15 @@
# binárka ev_on → setpoint ∈ {0} [min_power_w, max]; ev_direct ≤ gi + PV # 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í # (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, # 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[t1]; 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 → # - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve →
# reserve+noc, 619 h) platí za slot nájem buy×faktor (DB # reserve+noc, 619 h) platí za slot nájem buy×faktor (DB
# planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva # planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva
@@ -60,6 +68,9 @@ from typing import Any, Optional
import pulp import pulp
from services.planning.constants import ( from services.planning.constants import (
EV_MIN_CHARGE_CURRENT_A,
EV_MULTIPHASE_FLOOR_MIN_PHASES,
EV_PHASE_VOLTAGE_V,
INTERVAL_H, INTERVAL_H,
SOLVER_TIME_LIMIT, 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_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) 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_start_terms: list = [] # (ev_start var, penalta Kč) — anti-fragmentace (Fix B)
ev_min_w = [
max(0.0, float(getattr(vehicles[e], "min_power_w", 0) or 0)) for e in range(EV) 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] 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_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: if float(s.sell_price) < 0.0 and block_neg_sell:
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_sell_block_{t}" 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): 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): if not _connected(e, t):
prob += ev_direct[e][t] == 0 prob += ev_direct[e][t] == 0
prob += ev_via_bat[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: else:
ev_max_w = 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] ev_total = ev_direct[e][t] + ev_via_bat[e][t]
if 0 < ev_min_w[e] <= ev_max_w: if on_t is not None and ev_max_w > 0:
on = pulp.LpVariable(f"evon_{e}_{t}", cat=pulp.LpBinary) # on=1 nutné kdykoli ev_total > 0 (start penalta i floor to potřebují)
prob += ev_total >= ev_min_w[e] * on, f"ev_min_{e}_{t}" prob += ev_total <= ev_max_w * on_t, f"ev_max_{e}_{t}"
prob += ev_total <= ev_max_w * on, 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: else:
prob += ev_total <= ev_max_w prob += ev_total <= ev_max_w
# start = náběžná hrana ev_on (≥ on[t] on[t1]); 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) # provozní režimy (tvrdé constraints dle operating-modes.md)
if om == "SELF_SUSTAIN": 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) extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet)
if ev_opp: if ev_opp:
extras -= pulp.lpSum(o / 1000.0 * val for o, val in ev_opp if val > 0) 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_terms = [
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price)) nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
for t in range(T) for t in range(T)
@@ -521,6 +594,8 @@ def solve_dispatch_v2(
"slot_count": T, "slot_count": T,
"ev_sessions": sum(1 for x in ev_sessions if x is not None), "ev_sessions": sum(1 for x in ev_sessions if x is not None),
"ev_min_power_w": ev_min_w, "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, "masks_ignored": True,
"night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0), "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, "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), "terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet], "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_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_duration_ms": duration_ms,
"solver_status": status_str, "solver_status": status_str,

View File

@@ -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.';

View File

@@ -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( select coalesce(
jsonb_agg( jsonb_agg(
jsonb_build_object( jsonb_build_object(
'max_charge_power_w', v.max_charge_power_w, 'max_charge_power_w', v.max_charge_power_w,
'min_power_w', coalesce(ch.min_power_w, 0), '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, 'battery_capacity_kwh', v.battery_capacity_kwh,
'default_target_soc_pct', v.default_target_soc_pct 'default_target_soc_pct', v.default_target_soc_pct
) )
@@ -259,4 +265,4 @@ end;
$fn$; $fn$;
comment on function ems.fn_planning_site_context is 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).';

View File

@@ -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[t1]`, 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 % ## 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í). - **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í).