From a32839bf677f9c4945440314f4e5b261a1b04a93 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 14 Jun 2026 22:55:17 +0200 Subject: [PATCH 1/3] feat(planner): EV anti-fragmentace + 3f power floor (Fix B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/services/planning/constants.py | 10 ++ backend/services/planning/db_io.py | 9 ++ backend/services/planning/solver_v2.py | 99 +++++++++++++++++-- .../V108__asset_ev_charger_start_penalty.sql | 11 +++ .../R__039_fn_planning_site_context.sql | 8 +- docs/planning-changelog.md | 7 ++ 6 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 db/migration/V108__asset_ev_charger_start_penalty.sql 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í). From fc6d9833a7ab663e695466526ff5b429dc66a00c Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 14 Jun 2026 22:55:17 +0200 Subject: [PATCH 2/3] feat(ev): geofence arrival trigger (default-off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ev_vehicle_obs.trigger += 'geofence_arrival' (V109); presence cesta zapíše příjezd i bez píchnutí (za flagem EV_GEOFENCE_ARRIVAL_OBS_ENABLED, default OFF); fn_ev_build_trips páruje. Constraint name ověřen živě. Worktree agent. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/services/telemetry_collector.py | 85 +++++++++++++++++++ .../V109__ev_obs_geofence_trigger.sql | 26 ++++++ db/routines/R__096_fn_ev_usage.sql | 5 +- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 db/migration/V109__ev_obs_geofence_trigger.sql diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index 279f4d9..f75f8db 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -760,6 +760,27 @@ _EV_PRESENCE_LAST_DATA: dict[int, float] = {} _EV_PRESENCE_LAST_STATE: dict[int, str] = {} _EV_PLUG_NUDGE_LAST: dict[int, float] = {} +#: Geofence arrival obs (trigger='geofence_arrival') — příjezd domů BEZ píchnutí +#: do wallboxu. DEFAULT VYPNUTO (env EV_GEOFENCE_ARRIVAL_OBS_ENABLED=true zapne); +#: vypnuté = funkce běží jako dřív, jen se nový obs nezapisuje (golden gate / +#: plánovač beze změny). Debounce: vyžaduje N po sobě jdoucích čtení at_home=true +#: (GPS jitter u 150m hranice nesmí jeden flip brát jako příjezd). Dedup: emituje +#: jen jednou na epizodu (po emitu se "odzbrojí", znovu se "nabije" až po odjezdu); +#: a vůbec neběží, když je auto na wallboxu (plug-in cesta je autoritativní — +#: poll_tesla_presence se při otevřené session vrací dřív, viz `plugged`). +EV_GEOFENCE_ARRIVAL_CONFIRM_SAMPLES = 2 +_EV_GEOFENCE_HOME_STREAK: dict[int, int] = {} +_EV_GEOFENCE_ARMED: dict[int, bool] = {} + + +def _ev_geofence_obs_enabled() -> bool: + """Feature flag: zápis geofence_arrival obs (default false → inertní).""" + import os + + return (os.getenv("EV_GEOFENCE_ARRIVAL_OBS_ENABLED") or "").strip().lower() in ( + "1", "true", "yes", "on", + ) + def ev_presence_transition(prev_at_home: bool | None, new_at_home: bool | None) -> str | None: """Čistá detekce přechodu: 'arrived' / 'left' / None (testovatelné).""" @@ -772,6 +793,41 @@ def ev_presence_transition(prev_at_home: bool | None, new_at_home: bool | None) return None +def ev_geofence_arrival_decision( + vehicle_id: int, + at_home: bool | None, + confirm_samples: int = EV_GEOFENCE_ARRIVAL_CONFIRM_SAMPLES, +) -> bool: + """Debounce + dedup geofence příjezdu (čistá, testovatelná funkce nad stavem). + + Vstup `at_home` je výsledek aktuálního geofence čtení (None = poloha neznámá, + např. auto spí → stav se NEMĚNÍ). Vrací True právě jednou za epizodu příjezdu, + a to až po `confirm_samples` po sobě jdoucích čteních at_home=true: + + - at_home is None → neznámé, streak ani armed se nemění (žádné rozhodnutí). + - at_home is False → auto je pryč: vynuluj streak, "nabij" (armed=True), aby + příští potvrzený příjezd mohl emitovat. + - at_home is True → inkrementuj streak; pokud streak dosáhl prahu a jsme + armed, "odzbroj" (armed=False) a vrať True (emituj jednou). + + Tím se jeden GPS flip u hranice nepočítá jako příjezd a opakovaná at_home=true + čtení během stání doma negenerují duplicitní obs. + """ + if at_home is None: + return False + if at_home is False: + _EV_GEOFENCE_HOME_STREAK[vehicle_id] = 0 + _EV_GEOFENCE_ARMED[vehicle_id] = True + return False + # at_home is True + streak = _EV_GEOFENCE_HOME_STREAK.get(vehicle_id, 0) + 1 + _EV_GEOFENCE_HOME_STREAK[vehicle_id] = streak + if streak >= confirm_samples and _EV_GEOFENCE_ARMED.get(vehicle_id, False): + _EV_GEOFENCE_ARMED[vehicle_id] = False + return True + return False + + async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None: """Přítomnost vozidla: /vehicles state (nebudí) + při online poloha → geofence. @@ -830,6 +886,7 @@ async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None: distance_m = None charging_state = None shift_state = None + st = None if api_state == "online" and (woke_up or data_due): _EV_PRESENCE_LAST_DATA[int(veh["id"])] = loop_now try: @@ -865,6 +922,34 @@ async def poll_tesla_presence(site_id: int, db: asyncpg.Connection) -> None: int(veh["id"]), api_state, at_home, distance_m, charging_state, shift_state, ) + # Geofence příjezd (auto přijelo domů, NEpíchnuté — sem se dostaneme jen když + # NENÍ otevřená session, viz `plugged` výše: wallbox je autoritativní). Debounce + # + dedup řeší ev_geofence_arrival_decision; zápis je za feature flagem (default + # off → inertní). Zapisuje se z presence readu (st), proto jen když máme st se + # SoC i odometrem, ať jízda (km z odometru) dostane platný arrival. + if _ev_geofence_obs_enabled(): + emit = ev_geofence_arrival_decision(int(veh["id"]), at_home) + if emit and st is not None and st.get("battery_level") is not None: + try: + await db.execute( + "select ems.fn_ev_vehicle_obs_insert($1::int, $2::int, 'geofence_arrival', $3::numeric, $4::numeric, $5::text)", + site_id, + int(veh["id"]), + st.get("odometer_km"), + float(st["battery_level"]), + st.get("charging_state"), + ) + logger.info( + "EV geofence arrival obs (site=%s, vehicle=%s): soc=%s%%, odo=%s km", + site_id, veh["id"], + st["battery_level"], st.get("odometer_km"), + ) + except Exception: + logger.exception( + "EV geofence arrival obs failed (site=%s, vehicle=%s)", + site_id, veh["id"], + ) + trans = ev_presence_transition(prev["at_home"] if prev else None, at_home) if trans == "arrived" and charging_state == "Disconnected": if loop_now - _EV_PLUG_NUDGE_LAST.get(int(veh["id"]), 0.0) < EV_PLUG_NUDGE_COOLDOWN_S: diff --git a/db/migration/V109__ev_obs_geofence_trigger.sql b/db/migration/V109__ev_obs_geofence_trigger.sql new file mode 100644 index 0000000..68bedb7 --- /dev/null +++ b/db/migration/V109__ev_obs_geofence_trigger.sql @@ -0,0 +1,26 @@ +-- Geofence arrival trigger pro EV pozorování. +-- +-- Dosud arrival obs (ems.ev_vehicle_obs) vznikalo JEN z wallboxu (plug-in přes +-- fn_ev_session_transition). Když uživatel nepíchne, jízda se nezaznamenala a +-- spotřební forecast (ev_trip → ev_usage_stats) o ní nevěděl. +-- +-- Telemetry_collector už dnes z Tesla polohy (geofence, scope location, BEZ +-- buzení auta) detekuje přechod pryč→domů do ems.ev_presence_obs. Tato migrace +-- rozšiřuje povolené hodnoty ev_vehicle_obs.trigger o 'geofence_arrival', aby +-- presence cesta mohla zapsat příjezd i bez píchnutí do wallboxu. +-- +-- Zpětná kompatibilita: stávající hodnoty 'arrival' / 'departure' / 'manual' +-- zůstávají platné; přidává se jen nová hodnota. Žádná data se nemění. +-- Párování jízd (fn_ev_build_trips) bere 'geofence_arrival' jako platný arrival +-- (R__096); wallbox 'arrival' zůstává autoritativní, geofence je doplněk pro +-- případy, kdy auto stojí doma nepíchnuté. + +alter table ems.ev_vehicle_obs + drop constraint if exists ev_vehicle_obs_trigger_check; + +alter table ems.ev_vehicle_obs + add constraint ev_vehicle_obs_trigger_check + check (trigger in ('arrival', 'departure', 'manual', 'geofence_arrival')); + +comment on column ems.ev_vehicle_obs.trigger is +'Zdroj pozorování: arrival/departure z wallboxu (plug-in/out, autoritativní), manual ruční, geofence_arrival z Tesla polohy (přijel domů, nepíchnutý — auto vzhůru, čtení nebudí). geofence_arrival se páruje jako příjezd v fn_ev_build_trips.'; diff --git a/db/routines/R__096_fn_ev_usage.sql b/db/routines/R__096_fn_ev_usage.sql index 9c089d9..757d0e8 100644 --- a/db/routines/R__096_fn_ev_usage.sql +++ b/db/routines/R__096_fn_ev_usage.sql @@ -44,7 +44,7 @@ begin select a.* into v_arr from ems.ev_vehicle_obs a where a.vehicle_id = r.vehicle_id - and a.trigger = 'arrival' + and a.trigger in ('arrival', 'geofence_arrival') and a.observed_at > r.observed_at and a.odometer_km is not null order by a.observed_at @@ -79,6 +79,9 @@ begin end; $fn$; +comment on function ems.fn_ev_build_trips is +'Spáruje každý nespárovaný odjezd (trigger=departure) s nejbližším následujícím příjezdem téhož vozidla. Příjezd = trigger ''arrival'' (wallbox plug-in, autoritativní) NEBO ''geofence_arrival'' (Tesla poloha, auto přijelo domů nepíchnuté). km z odometru, kWh z ΔSoC.'; + -- Přepočet týdenního rytmu z jízd za lookback okno (plný přepočet, ne EMA — -- rebuild-friendly; jízdy s nabíjením cestou se počítají do km, ne do kWh). create or replace function ems.fn_update_ev_usage_stats( From c03f9dd9d6c2c3c3bb82d5c2954ed5731da0f225 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sun, 14 Jun 2026 22:55:17 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(ev):=20proaktivn=C3=AD=20notifikace=20?= =?UTF-8?q?'p=C3=ADchni=20auto'=20(default-off)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit job ev_presence_notify + fn_ev_presence_nudge_due (SQL-first rozhodnutí+dedup); asset_vehicle.presence_nudge_enabled default false=inertní (V110). Worktree agent. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/lifespan.py | 21 ++ backend/services/ev_presence_notify.py | 112 +++++++++++ db/migration/V110__ev_presence_notify.sql | 224 ++++++++++++++++++++++ docs/04-modules/ev-charging.md | 17 ++ 4 files changed, 374 insertions(+) create mode 100644 backend/services/ev_presence_notify.py create mode 100644 db/migration/V110__ev_presence_notify.sql diff --git a/backend/app/lifespan.py b/backend/app/lifespan.py index 74b4d9c..94e82fb 100644 --- a/backend/app/lifespan.py +++ b/backend/app/lifespan.py @@ -30,6 +30,7 @@ from services.signal_service import ( run_signal_outbound_send_for_active_sites, run_signal_outbound_verify_for_active_sites, ) +from services.ev_presence_notify import run_ev_presence_nudge_for_all_active_sites logger = logging.getLogger(__name__) @@ -161,6 +162,18 @@ async def lifespan(app: FastAPI): except Exception: logger.exception("scheduled_signal_outbound_verify failed") + async def scheduled_ev_presence_nudge() -> None: + """Proaktivní "auto doma + nepíchnuté + levné/přebytek → píchni ho". + + SQL-first rozhodnutí + dedup v ems.fn_ev_presence_nudge_due (insert do + ev_presence_nudge_sent). Default-off per vozidlo (presence_nudge_enabled), + takže job běží inertně, dokud se na nějakém vozidle nezapne. + """ + try: + await run_ev_presence_nudge_for_all_active_sites(app.state.pg_pool) + except Exception: + logger.exception("scheduled_ev_presence_nudge failed") + async def scheduled_pool_control() -> None: # Bazén: SQL-first rozhodnutí (fn_pool_control_tick) — nejlevnější souvislé # okno denního runtime + dump-load při sell<=0; zařadí POOL_PUMP_ON (jen když @@ -437,6 +450,14 @@ async def lifespan(app: FastAPI): id="pool_control", replace_existing=True, ) + scheduler.add_job( + scheduled_ev_presence_nudge, + "cron", + minute="5,30,55", + second=10, + id="ev_presence_nudge", + replace_existing=True, + ) scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan") scheduler.add_job( scheduled_rolling_replan, diff --git a/backend/services/ev_presence_notify.py b/backend/services/ev_presence_notify.py new file mode 100644 index 0000000..1e783c8 --- /dev/null +++ b/backend/services/ev_presence_notify.py @@ -0,0 +1,112 @@ +"""Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho". + +Tenký orchestrátor: veškerá doménová logika (kdo je doma, odpojený, výhodná cena, +SoC pod cílem) i dedup jsou v ems.fn_ev_presence_nudge_due(). Python jen zavolá +funkci pro každou aktivní lokalitu a pro každý vrácený (= nově due, ještě +neposlaný) řádek pošle jeden Discord nudge. + +Dedup je čistě v DB: funkce zapíše řádek do ems.ev_presence_nudge_sent +(on conflict do nothing) a vrátí jen ty, kterým insert skutečně prošel — tedy +jeden nudge na "epizodu" auta doma+odpojeno. Opakované 20–30min ticky proto +nespamují, dokud se auto nepíchne nebo neodjede (čímž se klíč epizody změní). + +DEFAULT-OFF: funkce nevrátí nic, dokud není na vozidle +asset_vehicle.presence_nudge_enabled = true. Job tedy běží inertně. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import asyncpg + +from app.db_json import fetch_json +from services.notification_service import send_discord + +logger = logging.getLogger(__name__) + + +def _fmt_price(value: Any) -> str: + try: + return f"{float(value):.2f}" + except (TypeError, ValueError): + return "?" + + +def _build_message(row: asyncpg.Record) -> str: + name = row["vehicle_name"] or "EV" + reason = str(row["trigger_reason"] or "") + sell = row["effective_sell_price_czk_kwh"] + buy = row["effective_buy_price_czk_kwh"] + soc = row["battery_level_pct"] + tgt = row["target_soc_pct"] + + if reason == "NEG_OR_ZERO_SELL": + why = f"výkup je teď {_fmt_price(sell)} Kč/kWh (≤ 0) — přebytek se hodí do auta" + else: + why = f"nákup je teď levný: {_fmt_price(buy)} Kč/kWh" + + soc_line = "" + if soc is not None: + soc_line = f"\nBaterie auta: **{_fmt_price(soc)} %**" + ( + f" (cíl {_fmt_price(tgt)} %)" if tgt is not None else "" + ) + + return ( + f"🚗 **{name} je doma a nepíchnuté** — {why}.{soc_line}\n" + f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty)." + ) + + +async def run_ev_presence_nudge_for_site( + site_id: int, conn: asyncpg.Connection +) -> int: + """Jedna lokalita: zavolá fn (dedup v DB) a pošle Discord pro každé due vozidlo. + + Vrátí počet odeslaných notifikací. + """ + try: + rows = await conn.fetch( + "select * from ems.fn_ev_presence_nudge_due($1::int)", + site_id, + ) + except Exception: + logger.exception( + "ev_presence_nudge: fn_ev_presence_nudge_due failed site=%s", site_id + ) + return 0 + + sent = 0 + for row in rows: + try: + await send_discord(conn, site_id, _build_message(row), level="info") + sent += 1 + logger.info( + "ev_presence_nudge sent site=%s vehicle=%s reason=%s", + site_id, + row["vehicle_id"], + row["trigger_reason"], + ) + except Exception: + logger.exception( + "ev_presence_nudge: Discord send failed site=%s vehicle=%s", + site_id, + row["vehicle_id"], + ) + return sent + + +async def run_ev_presence_nudge_for_all_active_sites(pool: asyncpg.Pool) -> None: + """Scheduler entrypoint: projde aktivní lokality a pošle proaktivní nudge.""" + async with pool.acquire() as conn: + raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()") + sites = raw if isinstance(raw, list) else [] + for site in sites: + if not isinstance(site, dict) or site.get("id") is None: + continue + site_id = int(site["id"]) + try: + await run_ev_presence_nudge_for_site(site_id, conn) + except Exception: + logger.exception("ev_presence_nudge site=%s failed", site_id) diff --git a/db/migration/V110__ev_presence_notify.sql b/db/migration/V110__ev_presence_notify.sql new file mode 100644 index 0000000..ac787bf --- /dev/null +++ b/db/migration/V110__ev_presence_notify.sql @@ -0,0 +1,224 @@ +-- Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho". +-- +-- Na rozdíl od arrival nudge v telemetry_collector (jen edge příjezd) tohle běží +-- periodicky (scheduler ~20–30 min) a upozorní, i když auto bylo doma už dřív, +-- ale je výhodné ho teď píchnout (sell<=0 NEBO velmi levný buy). +-- +-- SQL-first: rozhodnutí + dedup je v ems.fn_ev_presence_nudge_due(); Python jen IO/Discord. +-- DEFAULT-OFF: per vozidlo flag asset_vehicle.presence_nudge_enabled (default false) → +-- funkce nikoho nevrátí, dokud se na vozidle explicitně nezapne. Inertní pro golden gate. + +-- 1) SoC z presence pozorování (zatím plní jen budoucí telemetrie; NULL = neznámé). +alter table ems.ev_presence_obs + add column if not exists battery_level_pct numeric(5, 2); + +comment on column ems.ev_presence_obs.battery_level_pct is +'SoC trakční baterie vozidla v % z presence pollu (jen když je auto online a vrací charge_state). NULL = neznámé (auto spí / poloha bez SoC).'; + +-- 2) Per vozidlo: zapnutí proaktivní notifikace + cílový SoC práh pro nudge. +alter table ems.asset_vehicle + add column if not exists presence_nudge_enabled boolean not null default false; + +alter table ems.asset_vehicle + add column if not exists presence_nudge_soc_tolerance_pct numeric(5, 2) not null default 5; + +comment on column ems.asset_vehicle.presence_nudge_enabled is +'Zapne proaktivní Discord notifikaci "auto doma a nepíchnuté + levné/přebytek → píchni ho" (job ev_presence_notify). Default false = inertní.'; + +comment on column ems.asset_vehicle.presence_nudge_soc_tolerance_pct is +'Tolerance pod cílovým SoC: nudge se pošle jen když známé SoC < (default_target_soc_pct − tato tolerance). Pokud je SoC neznámé (NULL), prahem se neblokuje.'; + +-- 3) Dedup: jedno potvrzení odeslaného nudge na "epizodu" (vozidlo + klíč stavu). +-- nudge_key = observed_at začátku epizody, kdy auto JE doma a JE odpojené. +-- Dokud epizoda trvá (stejný start), klíč se nemění → on conflict do nothing tlumí +-- opakování každých 20–30 min. Po píchnutí / odjezdu epizoda končí; nový příjezd = +-- nový observed_at = nový klíč = nudge se znovu nabije. +create table if not exists ems.ev_presence_nudge_sent ( + vehicle_id int not null references ems.asset_vehicle (id), + nudge_key timestamptz not null, + sent_at timestamptz not null default now(), + primary key (vehicle_id, nudge_key) +); + +create index if not exists idx_ev_presence_nudge_sent_sent_at + on ems.ev_presence_nudge_sent (sent_at desc); + +comment on table ems.ev_presence_nudge_sent is +'Dedup proaktivních "píchni auto" notifikací: PK vehicle_id + nudge_key (start epizody doma+odpojeno). Jeden nudge na epizodu; po píchnutí/odjezdu se klíč přirozeně změní.'; + +-- 4) Rozhodovací funkce: vrátí vozidla, kde je teď výhodné píchnout, a zapíše dedup. +-- Podmínky (vše musí platit): +-- - vozidlo aktivní a presence_nudge_enabled = true, +-- - poslední pozorování se známou polohou: at_home = true, +-- - charging_state značí odpojeno (Disconnected / NoPower / null po příjezdu), +-- - žádná otevřená ev_session (auto reálně není na wallboxu), +-- - SoC neznámé NEBO SoC < (target − tolerance), +-- - aktuální 15min slot: efektivní sell <= 0 NEBO efektivní buy <= práh +-- (levný buy = pod p_cheap_buy_max_czk_kwh; statický práh, žádný kód zařízení). +-- Dedup: insert do ev_presence_nudge_sent (on conflict do nothing); vrací jen řádky, +-- pro které insert skutečně proběhl (= ještě neposláno pro tuto epizodu). +create or replace function ems.fn_ev_presence_nudge_due( + p_site_id int, + p_now timestamptz default now(), + p_cheap_buy_max_czk_kwh numeric default 1.50 +) +returns table ( + vehicle_id int, + vehicle_name text, + site_id int, + site_code text, + at_home boolean, + battery_level_pct numeric, + target_soc_pct numeric, + charging_state text, + effective_buy_price_czk_kwh numeric, + effective_sell_price_czk_kwh numeric, + trigger_reason text, + nudge_key timestamptz +) +language sql +volatile +as $fn$ + with slot as ( + -- aktuální 15min slot v UTC (zarovnání po Europe/Prague hranicích řeší boundary fn) + select ems.fn_planning_slot_boundary_prague(0, p_now) as interval_start + ), + veh as ( + select + v.id as vehicle_id, + v.name as vehicle_name, + v.site_id, + v.default_target_soc_pct, + v.presence_nudge_soc_tolerance_pct + from ems.asset_vehicle v + where v.site_id = p_site_id + and v.active = true + and v.presence_nudge_enabled = true + ), + last_obs as ( + -- poslední pozorování se ZNÁMOU polohou (at_home not null) per vozidlo + select distinct on (o.vehicle_id) + o.vehicle_id, + o.observed_at, + o.at_home, + o.charging_state, + o.battery_level_pct + from ems.ev_presence_obs o + join veh on veh.vehicle_id = o.vehicle_id + where o.at_home is not null + order by o.vehicle_id, o.observed_at desc + ), + episode as ( + -- poslední "zlom" epizody: nejnovější pozorování, kdy auto bylo pryč nebo připojené. + select + lo.vehicle_id, + coalesce( + ( + select max(o2.observed_at) + from ems.ev_presence_obs o2 + where o2.vehicle_id = lo.vehicle_id + and o2.observed_at <= lo.observed_at + and ( + o2.at_home is distinct from true + or ( + o2.charging_state is not null + and lower(o2.charging_state) not in ('disconnected', 'nopower') + ) + ) + ), + '-infinity'::timestamptz + ) as last_break_at + from last_obs lo + ), + episode_start as ( + select + ep.vehicle_id, + coalesce( + ( + select min(o3.observed_at) + from ems.ev_presence_obs o3 + where o3.vehicle_id = ep.vehicle_id + and o3.observed_at > ep.last_break_at + and o3.at_home = true + ), + -- fallback: žádný explicitní zlom v historii → ber poslední pozorování + (select observed_at from last_obs lo where lo.vehicle_id = ep.vehicle_id) + ) as nudge_key + from episode ep + ), + price as ( + select + ep.site_id, + ep.effective_buy_price_czk_kwh, + ep.effective_sell_price_czk_kwh + from ems.vw_site_effective_price ep, slot + where ep.site_id = p_site_id + and ep.interval_start = slot.interval_start + ), + due as ( + select + v.vehicle_id, + v.vehicle_name, + v.site_id, + lo.at_home, + lo.battery_level_pct, + v.default_target_soc_pct as target_soc_pct, + lo.charging_state, + pr.effective_buy_price_czk_kwh, + pr.effective_sell_price_czk_kwh, + es.nudge_key, + case + when pr.effective_sell_price_czk_kwh <= 0 then 'NEG_OR_ZERO_SELL' + else 'CHEAP_BUY' + end as trigger_reason + from veh v + join last_obs lo on lo.vehicle_id = v.vehicle_id + join episode_start es on es.vehicle_id = v.vehicle_id + cross join price pr + where lo.at_home = true + and ( + lo.charging_state is null + or lower(lo.charging_state) in ('disconnected', 'nopower') + ) + and not exists ( + select 1 + from ems.ev_session sess + where sess.vehicle_id = v.vehicle_id + and sess.session_end is null + ) + and ( + lo.battery_level_pct is null + or lo.battery_level_pct + < (coalesce(v.default_target_soc_pct, 80) - coalesce(v.presence_nudge_soc_tolerance_pct, 5)) + ) + and ( + pr.effective_sell_price_czk_kwh <= 0 + or pr.effective_buy_price_czk_kwh <= p_cheap_buy_max_czk_kwh + ) + ), + ins as ( + insert into ems.ev_presence_nudge_sent (vehicle_id, nudge_key) + select d.vehicle_id, d.nudge_key + from due d + on conflict (vehicle_id, nudge_key) do nothing + returning vehicle_id, nudge_key + ) + select + d.vehicle_id, + d.vehicle_name, + d.site_id, + (select s.code from ems.site s where s.id = d.site_id) as site_code, + d.at_home, + d.battery_level_pct, + d.target_soc_pct, + d.charging_state, + d.effective_buy_price_czk_kwh, + d.effective_sell_price_czk_kwh, + d.trigger_reason, + d.nudge_key + from due d + join ins on ins.vehicle_id = d.vehicle_id and ins.nudge_key = d.nudge_key; +$fn$; + +comment on function ems.fn_ev_presence_nudge_due is +'Proaktivní "píchni auto" notifikace: vozidla doma + odpojená + (SoC neznámé nebo < cíl−tolerance) + (efektivní sell<=0 nebo buy<=práh) v aktuálním 15min slotu. Default-off (asset_vehicle.presence_nudge_enabled). Dedup zápisem do ev_presence_nudge_sent (1 nudge na epizodu doma+odpojeno). Vrací jen nově due řádky pro Discord.'; diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 999861c..972bfc7 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -256,6 +256,23 @@ plánovač slepý k pokroku → phantom 11 kW okna i u plného auta. Funguje pro poslední taper k 100 % (jinak věčné mini-dobíjení → cyklování nabíječky / Tesla notifikace). `charge_done_tolerance_pct = 0` → tvrdě na target. +### Geofence arrival trigger (V109, default-off) + +Příjezd domů se dnes zaznamenává jen z wallboxu (plug-in). `ev_vehicle_obs.trigger` +nově povoluje **`geofence_arrival`**: presence cesta (`telemetry_collector`, z Tesla +polohy bez buzení) při přechodu pryč→domů u **nepíchnutého** auta zapíše obs (SoC, +odometr) → `fn_ev_build_trips` (R__096) ji spáruje jako příjezd → spotřební forecast +ví o jízdě i bez píchnutí. Za env flagem `EV_GEOFENCE_ARRIVAL_OBS_ENABLED` (default +OFF), debounce 2 vzorky, dedup s wallbox arrival (plugged = wallbox autoritativní). + +### Proaktivní notifikace „píchni auto" (V110, default-off) + +Job `ev_presence_notify` (~25 min) pošle Discord nudge, když je auto **doma + +nepíchnuté + (SoC neznámé nebo < target − tolerance) + (efektivní sell ≤ 0 nebo buy +pod prahem)**. SQL-first: rozhodnutí + dedup v `ems.fn_ev_presence_nudge_due` (dedup +přes `ev_presence_nudge_sent`, 1 nudge na epizodu doma+odpojeno). Per-vozidlo flag +`asset_vehicle.presence_nudge_enabled` (default **false** = inertní). + --- ## Statistika příjezdů