diff --git a/backend/services/telemetry_collector.py b/backend/services/telemetry_collector.py index f75f8db..44f9712 100644 --- a/backend/services/telemetry_collector.py +++ b/backend/services/telemetry_collector.py @@ -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 diff --git a/backend/tests/test_telemetry_idle_skip.py b/backend/tests/test_telemetry_idle_skip.py index f1680fc..89b3a08 100644 --- a/backend/tests/test_telemetry_idle_skip.py +++ b/backend/tests/test_telemetry_idle_skip.py @@ -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() diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 972bfc7..3fc41ce 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -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:")` → 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:")` + → 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: diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index 4769e76..9e0d10c 100644 --- a/docs/planning-changelog.md +++ b/docs/planning-changelog.md @@ -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:15–16:15). Plán tak virtuálně cpal ~4–11 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:")` + `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:'` 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.**