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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 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:
|
||||
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}"
|
||||
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,
|
||||
|
||||
11
db/migration/V108__asset_ev_charger_start_penalty.sql
Normal file
11
db/migration/V108__asset_ev_charger_start_penalty.sql
Normal 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.';
|
||||
@@ -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).';
|
||||
|
||||
@@ -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í).
|
||||
|
||||
Reference in New Issue
Block a user