merge dev → main: EV event-driven replan na odjezd (shodí fantomovou EV alokaci po odjezdu auta)
All checks were successful
CI and deploy / migration-check (push) Successful in 46s
CI and deploy / deploy (push) Successful in 1m12s

This commit is contained in:
Dusan Vojacek
2026-06-17 15:57:59 +02:00
4 changed files with 129 additions and 3 deletions

View File

@@ -129,13 +129,38 @@ async def _on_ev_arrival(site_id: int, charger_code: str) -> None:
async def _on_ev_departure(site_id: int, charger_code: str) -> None:
"""Odjezd: zapsat pozorování (odometer+SoC) — auto právě jede, je vzhůru.
"""Odjezd: okamžitý replan + export, pak zapsat pozorování jízdy.
Pár odjezd→příjezd dává jízdu (km, kWh) pro ev_usage_stats. Spící/nečitelné
auto (408) = tiché přeskočení, jízda se dopočítá z příštích pozorování.
Session už zavřela fn_ev_session_transition v poll smyčce (synchronně, PŘED
tímto fire-and-forget taskem), takže replan uvidí 'žádná session' a shodí
fantomovou EV alokaci místo čekání na */15 tick — symetricky k _on_ev_arrival.
Přebytek pak rovnou exportuje, ne aby ho plán virtuálně cpal už odjetému autu.
Pozorování (odometer+SoC): pár odjezd→příjezd dává jízdu (km, kWh) pro
ev_usage_stats. Spící/nečitelné auto (408) = tiché přeskočení; je best-effort
a NESMÍ blokovat ani shodit replan výše (proto vlastní conn + try).
"""
if _BG_POOL is None:
return
# 1) Okamžitý replan + export (kritické — uvolnit přebytek z fantomové EV alokace).
try:
from services.control_exporter import export_setpoints
from services.planning_engine import run_rolling_replan
async with _BG_POOL.acquire() as conn:
await run_rolling_replan(
site_id, conn, triggered_by=f"ev_departure:{charger_code}"
)
await export_setpoints(site_id, conn)
logger.info(
"EV departure replan+export done (site=%s, charger=%s)",
site_id, charger_code,
)
except Exception:
logger.exception(
"EV departure replan failed (site=%s, charger=%s)", site_id, charger_code
)
# 2) Pozorování jízdy (jen Tesla; nezávislé na replanu výše).
try:
from app.db_json import fetch_json
from services.tesla_client import get_charge_state

View File

@@ -185,5 +185,79 @@ class EvArrivalSurvivesIdleSkipTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(self.arrival_called)
class _FakeConn:
async def execute(self, *args: object, **kwargs: object) -> None:
return None
async def fetchval(self, *args: object, **kwargs: object) -> object:
return None
class _FakeAcquireCtx:
def __init__(self, conn: _FakeConn) -> None:
self._conn = conn
async def __aenter__(self) -> _FakeConn:
return self._conn
async def __aexit__(self, *exc: object) -> bool:
return False
class _FakePool:
def __init__(self) -> None:
self.conn = _FakeConn()
def acquire(self) -> _FakeAcquireCtx:
return _FakeAcquireCtx(self.conn)
class EvDepartureTriggersReplanTests(unittest.IsolatedAsyncioTestCase):
"""Odjezd EV musí okamžitě přeplánovat (ne čekat na */15) — symetrie k příjezdu."""
async def test_departure_triggers_replan_and_export(self) -> None:
import app.db_json as dbj
import services.control_exporter as ce
import services.planning_engine as pe
replan = AsyncMock()
export = AsyncMock()
# OBS část: non-tesla ctx → krátí se před voláním Tesla API.
fake_fetch = AsyncMock(return_value={"api_type": "loxone"})
with (
patch.object(tc, "_BG_POOL", _FakePool()),
patch.object(pe, "run_rolling_replan", replan),
patch.object(ce, "export_setpoints", export),
patch.object(dbj, "fetch_json", fake_fetch),
):
await tc._on_ev_departure(2, "vt-ev-charger-1")
replan.assert_awaited_once()
_, kwargs = replan.await_args
self.assertEqual(kwargs.get("triggered_by"), "ev_departure:vt-ev-charger-1")
export.assert_awaited_once()
async def test_departure_replan_failure_does_not_block_obs(self) -> None:
# Replan spadne → OBS část (jiný conn/try) musí proběhnout dál bez výjimky.
import app.db_json as dbj
import services.control_exporter as ce
import services.planning_engine as pe
replan = AsyncMock(side_effect=RuntimeError("solver down"))
export = AsyncMock()
fake_fetch = AsyncMock(return_value={"api_type": "loxone"})
with (
patch.object(tc, "_BG_POOL", _FakePool()),
patch.object(pe, "run_rolling_replan", replan),
patch.object(ce, "export_setpoints", export),
patch.object(dbj, "fetch_json", fake_fetch),
):
await tc._on_ev_departure(2, "vt-ev-charger-1") # nesmí vyhodit
replan.assert_awaited_once()
export.assert_not_awaited() # export se po pádu replanu nevolá
fake_fetch.assert_awaited() # OBS část přesto běžela
if __name__ == "__main__":
unittest.main()

View File

@@ -225,6 +225,25 @@ Modbus registr stavu konektoru (status):
Polling každou minutu z `telemetry_ev_charger.status`.
### Event-driven replan na příjezd i odjezd
Přechod stavu konektoru (`fn_ev_session_transition` v poll smyčce otevře/zavře
`ev_session` **synchronně**) spustí fire-and-forget hook v `telemetry_collector`,
který **okamžitě přeplánuje** místo čekání na `*/15` rolling tick:
- **Příjezd** (`available` → ≠`available`) → `_on_ev_arrival`: WB hold 0 A → (Tesla)
SoC do session → `run_rolling_replan(triggered_by="ev_arrival:<code>")` → export →
Discord. Auto se začne nabíjet hned, ne až za ≤15 min.
- **Odjezd** (≠`available``available`) → `_on_ev_departure`: session už zavřená
(transition běžel první) → `run_rolling_replan(triggered_by="ev_departure:<code>")`
→ export shodí **fantomovou EV alokaci** posledního planu (spočítaného ještě s autem)
a uvolní přebytek do sítě/baterie; pak best-effort pozorování jízdy (odometr+SoC).
Bez tohoto by plán spočítaný těsně před odjezdem držel EV load až do dalšího ticku
(≤15 min „o krok pozadu"); fyzicky neškodné (Deye přebytek vstřebá proti CT, export
hlídá reg 143), ale interní účtování planu bylo stale. Replan je v samostatném `try` +
vlastní conn, takže pád solveru ani spící auto (Tesla 408) jeden druhého neshodí.
### Tesla API (fáze 2)
Přes Tessie nebo přímé Tesla API:

View File

@@ -5,6 +5,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
---
## 2026-06-17 — EV: event-driven replan i na ODJEZD (ne jen příjezd)
- **Problém (živá triáž home-01):** auto odjelo v 15:15:23, ale poslední rolling plán (run spočítaný v 15:15:00, tj. 23 s PŘED odjezdem, kdy auto ještě viselo na 6 A) držel **fantomovou EV alokaci** (`ev1_setpoint_w = 11000`, sloty 15:1516:15). Plán tak virtuálně cpal ~411 kW do už odjetého auta místo do sítě/baterie. Fyzicky neškodné (WB prázdný → zápis reg 15 nic nenabíjí; Deye přebytek vstřebá proti CT; export hlídá reg 143 ≤ 13.5 kW), ale interní účtování planu bylo až do dalšího `*/15` ticku stale (≤15 min „o krok pozadu").
- **Příčina:** `_on_ev_arrival` v `telemetry_collector` už okamžitý replan spouštěl, ale `_on_ev_departure` dělal jen pozorování jízdy (odometr+SoC) — **na odjezd se replan nespouštěl**, čekalo se na rolling tick. Detekce sama je OK: `fn_ev_session_transition` session zavřela správně (ověřeno: session_end = 15:15:23, `fn_ev_session_planning_json` → „žádná session").
- **Mechanismus (fix):** `_on_ev_departure` nově **nejdřív** `run_rolling_replan(triggered_by="ev_departure:<code>")` + `export_setpoints` (session už zavřená v poll smyčce, takže replan vidí „žádná session" a EV alokaci shodí), **potom** stávající best-effort pozorování jízdy. Replan i OBS každý ve vlastním `try` + conn → pád solveru ani spící auto (Tesla 408) se navzájem neshodí. Symetrie k příjezdu.
- **Soubory:** `backend/services/telemetry_collector.py` (`_on_ev_departure`), `backend/tests/test_telemetry_idle_skip.py` (2 nové testy: replan+export se zavolá s `triggered_by`; pád replanu neblokuje OBS), `docs/04-modules/ev-charging.md` (sekce „Event-driven replan na příjezd i odjezd").
- **Ověření:** `test_telemetry_idle_skip.py` 12 passed. Mimo solver/SQL → golden gate beze změny. Živě: příští odjezd → `planning_run.triggered_by = 'ev_departure:<code>'` a plán bez EV alokace hned (ne až za ≤15 min).
## 2026-06-16 — control: reg 108 v PV_SURPLUS sleduje charge intent (BA81 nenabíjelo levné ráno)
- **Problém (triáž BA81):** výroba 12 kW (= ~2× nabíjecí rychlost baterky 6 kW), levné ranní výkupní ceny, baterka stála celé ráno na 29 % a vše šlo do sítě; nabíjet začala až odpoledne (dražší). Plán PŘITOM chtěl nabíjet (soc_tgt rostl), ale realita ne → promeškaná levná ranní arbitráž (~0.7 Kč/kWh). NEbyl to forecast (canonical ≈ realita) ani planner — **exekuce.**