merge dev → main: EV event-driven replan na odjezd (shodí fantomovou EV alokaci po odjezdu auta)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:<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.**
|
||||
|
||||
Reference in New Issue
Block a user