From 54288ee2fd0c6041db93b2a15e2d69f6499f7633 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 13 Jun 2026 22:03:11 +0200 Subject: [PATCH 1/2] fix(modbus): reg 15 re-asert kazdy tick + per-charger failsafe (BUG1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zivy incident home-01 (TeltoCharge .16): od ~22:45 UTC 12.6. nevznikl zadny telto journal radek (ani failed), auto jelo failsafe 8 A misto planovanych 0 A. Root cause: reg 15 (amps) byl write-on-change proti journalu (fn_modbus_device_state_map). Jakmile mel reg 15 radek "0 verified" a plan dal chtel 0, NIKDY nevznikl novy prikaz -- a TeltoCharge si po vypadku komunikace sam prepsal reg 15 na failsafe (reg 20) BEZ journal radku. Verify cte zpet jen 'written' radky, takze tichy drift 0 -> 8 A nikdo nevidel ani neopravil. - reg 15 (amps to use) se zapisuje VZDY (re-asert) -- volatilni ridici registr, ne EEPROM; drzi verify jobu cerstvy written radek -> drift se zachyti a hned opravi. _split_amps_and_watchdog odděluje 15 od 19/20. - reg 19/20 (watchdog config, EEPROM) zustavaji write-on-change. - per-charger failsafe/timeout: asset_ev_charger.watchdog_failsafe_a / watchdog_comm_timeout_s (V106; default 8 A / 300 s). "Zakaz nabijeni" = reg 15 = 0 (protokol rev 0.5 nema samostatny enable registr). - testy test_ev_write_on_change.py; docs teltocharge + journal + data-model. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/services/control/outputs.py | 113 ++++++++++++------ backend/tests/test_ev_write_on_change.py | 96 +++++++++------ .../V106__ev_charger_failsafe_current.sql | 18 +++ docs/03-data-model.md | 4 + docs/04-modules/modbus-command-journal.md | 29 +++-- .../modbus-registers-teltocharge.md | 69 +++++++---- 6 files changed, 229 insertions(+), 100 deletions(-) create mode 100644 db/migration/V106__ev_charger_failsafe_current.sql diff --git a/backend/services/control/outputs.py b/backend/services/control/outputs.py index 5eb024a..73bdf2c 100644 --- a/backend/services/control/outputs.py +++ b/backend/services/control/outputs.py @@ -18,33 +18,52 @@ logger = logging.getLogger(__name__) TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 6–32 A TELTO_REG_COMM_TIMEOUT_S = 19 # watchdog: bez komunikace → failsafe TELTO_REG_FAILSAFE_CURRENT_A = 20 -#: Výpadek EMS: po 5 min bez zápisu wallbox přejde na failsafe proud — -#: auto se přes noc nabije i bez EMS (pomalu), místo aby stálo na 0 A. +#: Výpadek EMS: po watchdog_comm_timeout_s bez komunikace wallbox přejde na +#: failsafe proud — auto se přes noc nabije i bez EMS (pomalu), místo aby +#: stálo na 0 A. Defaulty (fallback, když řádek chargeru nemá vlastní hodnoty). TELTO_WATCHDOG_TIMEOUT_S = 300 TELTO_WATCHDOG_FAILSAFE_A = 8 -def _telto_setpoint_registers(current_a: int) -> list[tuple[int, str, int]]: +def _telto_setpoint_registers( + current_a: int, + *, + comm_timeout_s: int = TELTO_WATCHDOG_TIMEOUT_S, + failsafe_a: int = TELTO_WATCHDOG_FAILSAFE_A, +) -> list[tuple[int, str, int]]: """Registry pro jeden export tick: limit proudu + watchdog konfigurace. - Write-on-change: volající VŽDY filtruje přes drop-unchanged proti - fn_modbus_device_state_map (poslední written/verified per registr) — - watchdog 19/20 se reálně zapíše jen po startu / po výpadku zařízení, - amps (15) jen při změně plánu. Watchdog timer TeltoCharge sytí jakákoli - validní Modbus komunikace (i FC3 čtení telemetrie každých 60 s), takže - periodické zápisy k udržení spojení NEJSOU potřeba (oficiální protokol, - docs/04-modules/modbus-registers-teltocharge.md). + **Reg 15 (amps to use) NENÍ write-on-change** — viz `_assert_amps_register`. + Je to volatilní řídicí registr: TeltoCharge ho po výpadku komunikace sám + přepíše na failsafe (reg 20), aniž by o tom vznikl journal řádek. Kdyby se + reg 15 dropoval proti journalu (poslední „0 verified"), EMS by tichý drift + 0 → 8 A NIKDY nezahlédlo (verify čte zpět jen `written` řádky) a nikdy ho + neopravilo. Proto se reg 15 re-asertuje KAŽDÝ export tick (≤ 8×/hod) — + EEPROM wear se týká jen konfiguračních 19/20, které write-on-change zůstávají. + + Watchdog timer TeltoCharge sytí jakákoli validní Modbus komunikace (i FC3 + čtení telemetrie každých 60 s), takže periodické zápisy k udržení spojení + NEJSOU potřeba; failsafe/timeout (19/20) per charger z DB. """ a = int(current_a) if a < 6: a = 0 return [ (TELTO_REG_AMPS_TO_USE, "telto_amps_to_use", min(a, 32)), - (TELTO_REG_COMM_TIMEOUT_S, "telto_comm_timeout_s", TELTO_WATCHDOG_TIMEOUT_S), - (TELTO_REG_FAILSAFE_CURRENT_A, "telto_failsafe_a", TELTO_WATCHDOG_FAILSAFE_A), + (TELTO_REG_COMM_TIMEOUT_S, "telto_comm_timeout_s", int(comm_timeout_s)), + (TELTO_REG_FAILSAFE_CURRENT_A, "telto_failsafe_a", max(0, min(int(failsafe_a), 32))), ] +def _split_amps_and_watchdog( + registers: list[tuple[int, str, int]], +) -> tuple[list[tuple[int, str, int]], list[tuple[int, str, int]]]: + """Rozdělí registry na (reg 15 = vždy zapsat) a (19/20 = write-on-change).""" + amps = [r for r in registers if r[0] == TELTO_REG_AMPS_TO_USE] + watchdog = [r for r in registers if r[0] != TELTO_REG_AMPS_TO_USE] + return amps, watchdog + + def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int: c = (charger_code or "").strip().lower() if c == "ev-charger-1": @@ -74,7 +93,8 @@ async def write_ev_setpoints( rows = await db.fetch( """ - SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id + SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id, + ec.watchdog_failsafe_a, ec.watchdog_comm_timeout_s FROM ems.asset_ev_charger ec JOIN ems.site_endpoint se ON se.id = ec.endpoint_id WHERE ec.site_id = $1 @@ -97,16 +117,31 @@ async def write_ev_setpoints( unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1) current_a = _current_limit_for_charger(code, setpoints) - registers = _telto_setpoint_registers(current_a) - # Write-on-change: poslední written/verified stav (ne jen verified) — - # zápis se nesmí opakovat každý tick, když verify čtení zaostává. + registers = _telto_setpoint_registers( + current_a, + comm_timeout_s=int( + row["watchdog_comm_timeout_s"] + if row["watchdog_comm_timeout_s"] is not None + else TELTO_WATCHDOG_TIMEOUT_S + ), + failsafe_a=int( + row["watchdog_failsafe_a"] + if row["watchdog_failsafe_a"] is not None + else TELTO_WATCHDOG_FAILSAFE_A + ), + ) + amps_regs, watchdog_regs = _split_amps_and_watchdog(registers) + # Reg 15 = vždy (re-asert proti tichému watchdog failsafe driftu na + # zařízení, který nemá journal řádek). Reg 19/20 = write-on-change + # proti fn_modbus_device_state_map (poslední written/verified stav). device_state = await _fetch_device_state_registers( site_id, asset_id, db, asset_type="ev_charger" ) - registers, skipped = _drop_registers_matching_last_verified( - registers, device_state + watchdog_regs, skipped = _drop_registers_matching_last_verified( + watchdog_regs, device_state ) - if not registers: + to_write = amps_regs + watchdog_regs + if not to_write: logger.debug("EV setpoint [%s]: beze změny (%s A)", code, current_a) continue @@ -119,7 +154,7 @@ async def write_ev_setpoints( host, port, unit_id, - registers, + to_write, db, ) ok = await execute_modbus_commands(cmd_ids, db) @@ -128,7 +163,7 @@ async def write_ev_setpoints( "EV setpoint [%s]: %s A (regs %s%s) -> %s", code, current_a, - [r for r, _, _ in registers], + [r for r, _, _ in to_write], f", skip {skipped}" if skipped else "", "written" if ok else "FAILED", ) @@ -155,7 +190,8 @@ async def write_ev_arrival_hold( row = await db.fetchrow( """ - SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id + SELECT ec.id AS asset_id, ec.code, se.host, se.port, se.unit_id, + ec.watchdog_failsafe_a, ec.watchdog_comm_timeout_s FROM ems.asset_ev_charger ec JOIN ems.site_endpoint se ON se.id = ec.endpoint_id WHERE ec.site_id = $1 @@ -170,20 +206,29 @@ async def write_ev_arrival_hold( if row is None: return False asset_id = int(row["asset_id"]) - registers = _telto_setpoint_registers(0) + registers = _telto_setpoint_registers( + 0, + comm_timeout_s=int( + row["watchdog_comm_timeout_s"] + if row["watchdog_comm_timeout_s"] is not None + else TELTO_WATCHDOG_TIMEOUT_S + ), + failsafe_a=int( + row["watchdog_failsafe_a"] + if row["watchdog_failsafe_a"] is not None + else TELTO_WATCHDOG_FAILSAFE_A + ), + ) + amps_regs, watchdog_regs = _split_amps_and_watchdog(registers) + # Reg 15 = 0 A se zapíše VŽDY (tvrdé zastavení po píchnutí kabelu; wallbox + # po připojení sám rozjíždí nabíjení defaultem). Reg 19/20 write-on-change. device_state = await _fetch_device_state_registers( site_id, asset_id, db, asset_type="ev_charger" ) - registers, skipped = _drop_registers_matching_last_verified( - registers, device_state + watchdog_regs, skipped = _drop_registers_matching_last_verified( + watchdog_regs, device_state ) - if not registers: - logger.info( - "EV arrival hold [%s]: 0 A už na zařízení (skip %s)", - charger_code, - skipped, - ) - return True + to_write = amps_regs + watchdog_regs cmd_ids = await create_modbus_commands( site_id, None, @@ -193,14 +238,14 @@ async def write_ev_arrival_hold( str(row["host"]), int(row["port"] or 502), int(row["unit_id"] if row["unit_id"] is not None else 1), - registers, + to_write, db, ) ok = await execute_modbus_commands(cmd_ids, db) logger.info( "EV arrival hold [%s]: 0 A (regs %s%s) %s", charger_code, - [r for r, _, _ in registers], + [r for r, _, _ in to_write], f", skip {skipped}" if skipped else "", "written" if ok else "FAILED", ) diff --git a/backend/tests/test_ev_write_on_change.py b/backend/tests/test_ev_write_on_change.py index c1efbd3..0b13958 100644 --- a/backend/tests/test_ev_write_on_change.py +++ b/backend/tests/test_ev_write_on_change.py @@ -1,11 +1,13 @@ -"""Write-on-change pro TeltoCharge: zápis JEN při skutečné změně hodnoty. +"""TeltoCharge zápis: reg 15 (amps) VŽDY, watchdog 19/20 write-on-change. -Regrese na opakované zápisy do wallboxu (EEPROM wear): export tick běží -~8x/hod (control_export :14,:29,:44,:59 + rolling replan */15 s exportem), -ale reg 15/19/20 se smí zapsat jen při změně proti poslednímu known stavu -zařízení (fn_modbus_device_state_map: nejnovější written/verified řádek -journalu per registr). Watchdog (19/20) se zapíše jednou po startu / po -výpadku; sytí ho i FC3 čtení telemetrie (60 s), periodické zápisy netřeba. +Export tick běží ~8x/hod (control_export :14,:29,:44,:59 + rolling replan +*/15 s exportem). **Reg 15 (amps to use) se zapisuje VŽDY** — TeltoCharge ho +po výpadku komunikace sám přepíše na failsafe (reg 20) bez journal řádku, a +kdyby byl write-on-change, EMS by tichý drift 0 → 8 A nikdy nezahlédlo +(verify čte zpět jen `written`). **Reg 19/20 (watchdog config, EEPROM wear) +zůstávají write-on-change** proti fn_modbus_device_state_map (nejnovější +written/verified řádek per registr): zapíší se jednou po startu / po výpadku; +sytí je i FC3 čtení telemetrie (60 s), periodické zápisy netřeba. """ import unittest @@ -20,6 +22,7 @@ from services.control.outputs import ( TELTO_REG_FAILSAFE_CURRENT_A, TELTO_WATCHDOG_FAILSAFE_A, TELTO_WATCHDOG_TIMEOUT_S, + _split_amps_and_watchdog, _telto_setpoint_registers, write_ev_arrival_hold, write_ev_setpoints, @@ -59,40 +62,48 @@ class TeltoSetpointRegistersTests(unittest.TestCase): self.assertEqual(_telto_setpoint_registers(6)[0][2], 6) self.assertEqual(_telto_setpoint_registers(40)[0][2], 32) + def test_per_charger_failsafe_and_timeout(self) -> None: + regs = _telto_setpoint_registers(0, comm_timeout_s=120, failsafe_a=6) + self.assertEqual([(r, v) for r, _, v in regs], [(15, 0), (19, 120), (20, 6)]) + + def test_failsafe_clamped_to_0_32(self) -> None: + self.assertEqual(_telto_setpoint_registers(0, failsafe_a=99)[2][2], 32) + self.assertEqual(_telto_setpoint_registers(0, failsafe_a=-5)[2][2], 0) + + def test_split_separates_amps_from_watchdog(self) -> None: + amps, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0)) + self.assertEqual([r for r, _, _ in amps], [15]) + self.assertEqual([r for r, _, _ in watchdog], [19, 20]) + class DropAgainstDeviceStateTests(unittest.TestCase): - def test_steady_state_drops_everything(self) -> None: + def test_watchdog_steady_state_drops_19_20(self) -> None: + _, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0)) out, skipped = _drop_registers_matching_last_verified( - _telto_setpoint_registers(0), _STEADY_STATE_0A + watchdog, _STEADY_STATE_0A ) self.assertEqual(out, []) - self.assertEqual(skipped, [15, 19, 20]) - - def test_amps_change_writes_only_reg_15(self) -> None: - out, skipped = _drop_registers_matching_last_verified( - _telto_setpoint_registers(16), _STEADY_STATE_0A - ) - self.assertEqual([(r, v) for r, _, v in out], [(15, 16)]) self.assertEqual(skipped, [19, 20]) - def test_empty_state_after_outage_writes_full_triple(self) -> None: - out, skipped = _drop_registers_matching_last_verified( - _telto_setpoint_registers(0), {} - ) - self.assertEqual([r for r, _, _ in out], [15, 19, 20]) + def test_empty_state_after_outage_keeps_19_20(self) -> None: + _, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0)) + out, skipped = _drop_registers_matching_last_verified(watchdog, {}) + self.assertEqual([r for r, _, _ in out], [19, 20]) self.assertEqual(skipped, []) class _FakeDB: """Jen řádky chargeru; journal funkce se patchují v modbus_journal.""" - def __init__(self) -> None: + def __init__(self, failsafe_a: int = 8, comm_timeout_s: int = 300) -> None: self.row = { "asset_id": 7, "code": "ev-charger-1", "host": "172.16.1.16", "port": 502, "unit_id": 1, + "watchdog_failsafe_a": failsafe_a, + "watchdog_comm_timeout_s": comm_timeout_s, } async def fetch(self, query: str, *args: object) -> list[dict]: @@ -105,9 +116,9 @@ class _FakeDB: raise AssertionError(f"unexpected fetchval: {query}") -class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase): +class WriteEvSetpointsTests(unittest.IsolatedAsyncioTestCase): async def _run( - self, device_state: dict[int, int], ev1_a: int + self, device_state: dict[int, int], ev1_a: int, db: _FakeDB | None = None ) -> tuple[AsyncMock, AsyncMock]: create = AsyncMock(return_value=[1, 2, 3]) execute = AsyncMock(return_value=True) @@ -120,13 +131,17 @@ class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase): patch.object(journal, "create_modbus_commands", create), patch.object(journal, "execute_modbus_commands", execute), ): - await write_ev_setpoints(1, _setpoints(ev1_a), _FakeDB()) # type: ignore[arg-type] + await write_ev_setpoints(1, _setpoints(ev1_a), db or _FakeDB()) # type: ignore[arg-type] return create, execute - async def test_steady_state_no_write_at_all(self) -> None: + async def test_steady_state_still_reasserts_reg_15(self) -> None: + # Reg 15 se zapisuje VŽDY (re-asert proti tichému failsafe driftu), + # i když je device-state mapa shodná. Watchdog 19/20 se přeskočí. create, execute = await self._run(_STEADY_STATE_0A, ev1_a=0) - create.assert_not_awaited() - execute.assert_not_awaited() + create.assert_awaited_once() + registers = create.await_args.args[8] + self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)]) + execute.assert_awaited_once() async def test_plan_change_writes_only_amps(self) -> None: create, execute = await self._run(_STEADY_STATE_0A, ev1_a=16) @@ -135,14 +150,24 @@ class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase): self.assertEqual([(r, v) for r, _, v in registers], [(15, 16)]) execute.assert_awaited_once() - async def test_after_outage_writes_full_triple(self) -> None: + async def test_after_outage_writes_amps_then_watchdog(self) -> None: create, execute = await self._run({}, ev1_a=0) registers = create.await_args.args[8] self.assertEqual([r for r, _, _ in registers], [15, 19, 20]) execute.assert_awaited_once() + async def test_per_charger_failsafe_from_db(self) -> None: + # Failsafe 6 A z DB → po výpadku se zapíše reg 20 = 6 (prázdná mapa). + create, _ = await self._run( + {}, ev1_a=0, db=_FakeDB(failsafe_a=6, comm_timeout_s=120) + ) + registers = create.await_args.args[8] + self.assertEqual( + [(r, v) for r, _, v in registers], [(15, 0), (19, 120), (20, 6)] + ) -class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase): + +class WriteEvArrivalHoldTests(unittest.IsolatedAsyncioTestCase): async def _run( self, device_state: dict[int, int] ) -> tuple[bool, AsyncMock, AsyncMock]: @@ -160,13 +185,15 @@ class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase): ok = await write_ev_arrival_hold(1, "ev-charger-1", _FakeDB()) # type: ignore[arg-type] return ok, create, execute - async def test_hold_skips_when_device_already_at_zero(self) -> None: + async def test_hold_always_writes_reg_15_even_if_device_at_zero(self) -> None: + # Tvrdé zastavení po píchnutí kabelu — reg 15 = 0 se zapíše VŽDY. ok, create, execute = await self._run(_STEADY_STATE_0A) self.assertTrue(ok) - create.assert_not_awaited() - execute.assert_not_awaited() + registers = create.await_args.args[8] + self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)]) + execute.assert_awaited_once() - async def test_hold_writes_only_amps_when_watchdog_already_set(self) -> None: + async def test_hold_writes_amps_and_watchdog_when_device_drifted(self) -> None: ok, create, execute = await self._run( { TELTO_REG_AMPS_TO_USE: 16, @@ -176,6 +203,7 @@ class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase): ) self.assertTrue(ok) registers = create.await_args.args[8] + # Reg 15 = 0 zapsán (i když device hlásí 16); 19/20 shodné → skip. self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)]) execute.assert_awaited_once() diff --git a/db/migration/V106__ev_charger_failsafe_current.sql b/db/migration/V106__ev_charger_failsafe_current.sql new file mode 100644 index 0000000..2cbd7f3 --- /dev/null +++ b/db/migration/V106__ev_charger_failsafe_current.sql @@ -0,0 +1,18 @@ +-- Per-charger watchdog failsafe proud (reg 20 TeltoCharge) + comm timeout (reg 19). +-- Failsafe = proud, na který wallbox spadne po výpadku komunikace EMS delším než +-- comm timeout. Default 8 A historicky (auto se přes noc dobije pomalu i bez EMS), +-- ale po ZAPOJENÍ má jet řízeně z plánu (0 A drží arrival-hold + watchdog sycení +-- čtením telemetrie), ne failsafe. Konfigurovatelné per charger, ať lze failsafe +-- snížit (např. 6 A) nebo zvednout dle dotačních / komfortních požadavků. +-- +-- Sémantika: hodnota PŘI výpadku EMS, ne při běžném provozu. Proto se obvykle +-- drží min 6 A (IEC 61851 minimum); 0 = po výpadku vědomě nenabíjet. + +alter table ems.asset_ev_charger + add column if not exists watchdog_failsafe_a int not null default 8, + add column if not exists watchdog_comm_timeout_s int not null default 300; + +comment on column ems.asset_ev_charger.watchdog_failsafe_a is + 'TeltoCharge reg 20: proud (A) při výpadku komunikace EMS déle než watchdog_comm_timeout_s. Default 8 A (pomalé dobití bez EMS). 0 = po výpadku nenabíjet. Běžný provoz řídí reg 15 z plánu, ne failsafe.'; +comment on column ems.asset_ev_charger.watchdog_comm_timeout_s is + 'TeltoCharge reg 19: timeout (s) bez validní Modbus komunikace, po kterém wallbox přejde na watchdog_failsafe_a. Sytí ho i FC3 čtení telemetrie (60 s). Default 300 s.'; diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 31bfe33..47300c6 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -175,6 +175,10 @@ CREATE TABLE asset_ev_charger ( phases INT DEFAULT 3, connector_count INT DEFAULT 1, schedulable BOOLEAN DEFAULT true, + -- TeltoCharge watchdog (V106): reg 19/20. Failsafe = proud po výpadku + -- komunikace EMS; běžný provoz řídí reg 15 z plánu, ne failsafe. + watchdog_failsafe_a INT NOT NULL DEFAULT 8, -- reg 20: 0–32 A (0 = po výpadku nenabíjet) + watchdog_comm_timeout_s INT NOT NULL DEFAULT 300, -- reg 19: s bez komunikace → failsafe notes TEXT ); ``` diff --git a/docs/04-modules/modbus-command-journal.md b/docs/04-modules/modbus-command-journal.md index 873d5c9..fb2d350 100644 --- a/docs/04-modules/modbus-command-journal.md +++ b/docs/04-modules/modbus-command-journal.md @@ -48,20 +48,29 @@ Implementace: `services/control_exporter.py` — `verify_modbus_commands`, `_ver ## EV wallbox (TeltoCharge) `write_ev_setpoints` (každý export tick) a `write_ev_arrival_hold` (po detekci -příjezdu EV) zapisují registry **15** (amps to use), **19** (comm timeout 300 s) -a **20** (failsafe 8 A) — vždy přes journal (`asset_type = 'ev_charger'`). +příjezdu EV) zapisují registry **15** (amps to use), **19** (comm timeout) a +**20** (failsafe) — vždy přes journal (`asset_type = 'ev_charger'`). Timeout +a failsafe jsou per charger (`asset_ev_charger.watchdog_comm_timeout_s` / +`watchdog_failsafe_a`, V106; default 300 s / 8 A). - **Verify job ověřuje všechny asset typy** — `fn_modbus_written_command_ids` nefiltruje podle `asset_type` a registry 15/19/20 jsou dle protokolu R/W (čtou se zpět standardní FC 3 větví). -- **Write-on-change:** před zápisem se registry filtrují proti - **`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu per registr; - hodnota jen pro stav `written`/`verified`). Shodná hodnota ⇒ zápis se - přeskočí. Na rozdíl od `fn_modbus_last_verified_map` (Deye drop-unchanged) - nečeká na verify — `written` stačí, takže pomalý/neúspěšný verify read - nevede k opakovaným zápisům každý tick (EEPROM wear). Nejnovější řádek - `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ po výpadku zařízení se - konfigurace (vč. watchdog 19/20) obnoví jedním zápisem. +- **Reg 15 (amps) se zapisuje KAŽDÝ tick** (re-asert), **NE write-on-change.** + Incident 2026-06-13: TeltoCharge si po výpadku komunikace sám přepíše reg 15 + na failsafe (reg 20) bez journal řádku; write-on-change proti journalu + (poslední „0 verified") by tichý drift **0 → 8 A** nikdy nezahlédlo (verify + čte zpět jen `written`) a nikdy neopravilo. Re-asert každý tick drift opraví + a drží verify jobu čerstvý `written` reg-15 řádek. Reg 15 je volatilní řídicí + registr (ne EEPROM). +- **Reg 19/20 (watchdog config) zůstávají write-on-change:** před zápisem se + filtrují proti **`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu + per registr; hodnota jen pro stav `written`/`verified`). Shodná hodnota ⇒ + zápis se přeskočí. Na rozdíl od `fn_modbus_last_verified_map` (Deye + drop-unchanged) nečeká na verify — `written` stačí, takže pomalý/neúspěšný + verify read nevede k opakovaným zápisům každý tick (EEPROM wear). Nejnovější + řádek `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ po výpadku zařízení se + watchdog 19/20 obnoví jedním zápisem. - **Mismatch po 3 pokusech NEpřepíná SELF_SUSTAIN** — fallback režim je Deye politika (`asset_type = 'inverter'`); u wallboxu zůstane řádek `mismatch` + Discord (`notify_modbus_mismatch`). diff --git a/docs/04-modules/modbus-registers-teltocharge.md b/docs/04-modules/modbus-registers-teltocharge.md index 8c350a9..8295002 100644 --- a/docs/04-modules/modbus-registers-teltocharge.md +++ b/docs/04-modules/modbus-registers-teltocharge.md @@ -32,42 +32,67 @@ ukončil session a EV výkon 0 by špinil bazál (pravidlo 15). | Reg | R/W | Význam | Hodnoty | EMS zapisuje | |-----|-----|--------|---------|--------------| -| 15 | R/W | **Amps to use** (limit proudu) | 0 = stop, 6–32 A | hodnota z plánu (`ev{1,2}_current_a`); příjezd EV → hold 0 A | -| 16 | R/W | Start/Stop session | 0 nic · 1 stop · 2 start | ne | -| 19 | R/W | Communication timeout (watchdog) | 0–600 s (0 = vypnuto), default 30 | `TELTO_WATCHDOG_TIMEOUT_S` = **300** | -| 20 | R/W | Failsafe current | 0, 6–32 A, default 6 | `TELTO_WATCHDOG_FAILSAFE_A` = **8** | +| 15 | R/W | **Amps to use** (limit proudu) | 0 = stop, 6–32 A | hodnota z plánu (`ev{1,2}_current_a`); příjezd EV → hold 0 A. **Zapisuje se KAŽDÝ tick** (re-asert, ne write-on-change — viz níže) | +| 16 | R/W | Start/Stop session | 0 nic · 1 stop · 2 start | ne (tvrdé zastavení řešíme reg 15 = 0) | +| 19 | R/W | Communication timeout (watchdog) | 0–600 s (0 = vypnuto), default 30 | per charger `asset_ev_charger.watchdog_comm_timeout_s` (default **300**) | +| 20 | R/W | Failsafe current | 0, 6–32 A, default 6 | per charger `asset_ev_charger.watchdog_failsafe_a` (default **8**) | Všechny čtyři registry jsou dle oficiálního protokolu (wiki *External control RS485* / protokol rev 0.5) **R/W** — verify job je čte zpět standardní FC 3 větví (žádný write-only registr v této sadě). -### Write-on-change — POVINNÉ (EEPROM wear) +**„Zákaz nabíjení" = reg 15 = 0.** Protokol rev 0.5 v této sadě **nemá** +samostatný boolean „charging enable/disable" registr — řízení je proudovým +limitem (reg 15: 0 = stop) plus volitelně reg 16 (1 = stop session). EMS +používá **reg 15 = 0** jako řízené zastavení (arrival-hold i běžný plán); +reg 16 se nezapisuje. Failsafe (reg 20) je hodnota PŘI výpadku komunikace, +ne při běžném provozu — běžně auto stojí na 0 A, dokud plán neřekne jinak. + +### Reg 15 (amps) — VŽDY re-asert; reg 19/20 — write-on-change (EEPROM) Export tick běží ~8×/hod (control_export `:14,:29,:44,:59` + rolling replan -`*/15` s exportem). Zápis do wallboxu se proto provádí **jen při skutečné -změně hodnoty**: `write_ev_setpoints` i `write_ev_arrival_hold` filtrují -registry přes `_drop_registers_matching_last_verified` proti -**`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu per registr -se stavem `written` **nebo** `verified`). Důsledky: +`*/15` s exportem). -- **reg 15** se zapíše jen při změně plánovaného proudu (0 ↔ 6–32 A) — to je - legitimní zápis; -- **reg 19/20** se zapíší jednou po nasazení / po výpadku zařízení (nejnovější - řádek `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ znovu se zapíše) a pak - už nikdy, dokud se hodnota nezmění; -- čekání na verify **neblokuje** skip — `written` (TCP ack) stačí, mismatch - z verify stav mapy zneplatní a vynutí nový zápis. +- **reg 15 (amps to use) se zapisuje při KAŽDÉM ticku** (`write_ev_setpoints` + i `write_ev_arrival_hold`). **Důvod (incident 2026-06-13):** TeltoCharge si + po výpadku komunikace sám přepíše reg 15 na failsafe (reg 20) — bez journal + řádku. Kdyby byl reg 15 write-on-change proti journalu (poslední + „0 verified"), EMS by tichý drift **0 → 8 A** na zařízení **NIKDY + nezahlédlo** (verify čte zpět jen `written` řádky) a nikdy ho neopravilo: + auto po každém krátkém výpadku spojení tiše jelo 8 A místo plánovaných 0 A. + Reg 15 je volatilní řídicí registr (ne EEPROM), opakovaný zápis je v pořádku; + re-asert každý tick zároveň drží verify jobu čerstvý `written` reg-15 řádek + → případný drift se zachytí a hned opraví. +- **reg 19/20 (watchdog config) zůstávají write-on-change** přes + `_drop_registers_matching_last_verified` proti **`ems.fn_modbus_device_state_map`** + (nejnovější řádek journalu per registr, stav `written` **nebo** `verified`): + zapíší se jednou po nasazení / po výpadku zařízení (nejnovější řádek + `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ znovu se zapíše) a pak už ne, + dokud se hodnota nezmění — šetří EEPROM. Čekání na verify skip neblokuje, + `written` (TCP ack) stačí. -### Watchdog — sytí ho i čtení +Implementace: `_telto_setpoint_registers` (per-charger failsafe/timeout), +`_split_amps_and_watchdog` (reg 15 vs 19/20) v `services/control/outputs.py`. + +### Watchdog — sytí ho i čtení; failsafe konfigurovatelný Protokol definuje timeout jako *„if no **valid communication** is present after a configurable time interval…"* — timer resetuje **jakákoli** validní Modbus komunikace s unit ID wallboxu, **včetně FC 3 čtení**. Telemetrie čte blok 0–40 každých **60 s**, takže watchdog 300 s je trvale sycen čtením a -**periodické zápisy k udržení spojení nejsou potřeba**. Failsafe (omezení na -8 A, reg 20 „max allowed current on comm timeout") nastane až po 5 min bez -jakéhokoli pollingu = skutečný výpadek EMS; auto se pak přes noc dobije -pomalu místo stání na 0 A. +**periodické zápisy k udržení spojení nejsou potřeba**. Failsafe (reg 20 +„max allowed current on comm timeout") nastane až po `watchdog_comm_timeout_s` +bez jakéhokoli pollingu = skutečný výpadek EMS. + +**Failsafe je per charger** (`asset_ev_charger.watchdog_failsafe_a`, default +8 A; `watchdog_comm_timeout_s`, default 300 s; migrace V106): +- default **8 A** = po skutečném výpadku EMS se auto přes noc pomalu dobije + místo stání na 0 A; +- snížit lze na **6 A** (IEC 61851 minimum) nebo **0** (po výpadku nenabíjet), + dle dotačních / komfortních požadavků; +- **běžný provoz po zapojení řídí reg 15 z plánu** (0 A drží arrival-hold + + sycení watchdogu čtením telemetrie), failsafe se uplatní jen při výpadku — + rozpor „chci řízený default 0 A, ale po výpadku malý proud" je tím vyřešen. ## WB2 mimo EMS (V105, 2026-06-13) From d81a150014508d38e4a84e899b84bb5e435d952d Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Sat, 13 Jun 2026 22:03:27 +0200 Subject: [PATCH 2/2] fix(planner): EV session viditelna i bez deadline / nad targetem (BUG2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zivy incident home-01: aktivni plan mel ev_sessions:0, ac session bezela (target 70 %). Planovac neviděl ~6 kW zatez auta a spatne rozvrhl baterii (zbytecny vecerni import). Root cause (dve pasti): - fn_planning_site_context vracela session jako null, kdyz needed_wh=0 (auto nad targetem) i kdyz target_deadline is null. - _ev_session_from_json (Python) zahazovala session bez deadline. Fix: - R__038 fn_ev_session_planning_json: session se vyradi (null) JEN bez tvrdych dat (kapacita vozidla / soc_at_connect). target_deadline smi byt NULL -- solver hard deadline constraint aplikuje jen pri needed>0; oportunisticka vrstva bezi i bez deadline. Auto nad targetem zustava v planu jako znama zatez i s headroomem k levnemu doplneni. R__039 vola helper (deduplikace dvou inline poddotazu, SQL-first). - _ev_session_from_json si NULL deadline ponecha (energy_needed_wh default 0). - testy test_ev_session_parse.py; docs ev-charging + planning-changelog; CLAUDE.md funkce. Navrh agresivnejsiho oportunistickeho algoritmu (P50 levnych oken z market_price_stats misto konstanty 1 Kc/kWh) -- NEnasazeno, k rozhodnuti, sepsano v docs/04-modules/planning.md (EV oportunismus); riziko regrese golden ekonomiky, nutny EV fixture + eval. Overeni: pytest -q 362 passed; golden replay gate 7 passed; solver_v2_eval beze zmeny (fixtures bez EV session). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- backend/services/planning/db_io.py | 9 +- backend/tests/test_ev_session_parse.py | 66 ++++++++++ .../R__038_fn_ev_session_planning_json.sql | 76 ++++++++++++ .../R__039_fn_planning_site_context.sql | 113 +----------------- docs/04-modules/ev-charging.md | 20 ++++ docs/04-modules/planning.md | 41 +++++++ docs/planning-changelog.md | 8 ++ 8 files changed, 224 insertions(+), 111 deletions(-) create mode 100644 backend/tests/test_ev_session_parse.py create mode 100644 db/routines/R__038_fn_ev_session_planning_json.sql diff --git a/CLAUDE.md b/CLAUDE.md index 9f3af7f..d958331 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st | `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). | | `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. | -**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_ev_session_defaults` (kaskáda ev_weekly_requirement → forecast → defaulty), `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_modbus_device_state_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`. +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_ev_session_defaults` (kaskáda ev_weekly_requirement → forecast → defaulty), `fn_ev_session_planning_json` (EV session pro LP; nevyřazuje při needed=0), `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_modbus_device_state_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`. --- diff --git a/backend/services/planning/db_io.py b/backend/services/planning/db_io.py index f6aff1a..5e5fde5 100644 --- a/backend/services/planning/db_io.py +++ b/backend/services/planning/db_io.py @@ -31,12 +31,15 @@ def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]: obj = json.loads(obj) if not isinstance(obj, dict): return None + # target_deadline SMÍ být None: oportunistická session (auto nad targetem, + # nebo bez nastaveného cíle) zůstává v plánu kvůli headroomu i jako známá + # zátěž. Tvrdý deadline constraint se aplikuje jen při energy_needed_wh > 0 + # (a needed > 0 nastane jen s deadlinem). Dřív se taková session zahazovala + # (None) a plánovač pak neviděl zátěž auta — bug 2026-06-13. td = _parse_json_dt(obj.get("target_deadline")) - if td is None: - return None return SimpleNamespace( target_deadline=td, - energy_needed_wh=float(obj["energy_needed_wh"]), + energy_needed_wh=float(obj.get("energy_needed_wh") or 0.0), headroom_wh=float(obj.get("headroom_wh") or 0.0), opportunistic_value_czk_kwh=float(obj.get("opportunistic_value_czk_kwh") or 0.0), ) diff --git a/backend/tests/test_ev_session_parse.py b/backend/tests/test_ev_session_parse.py new file mode 100644 index 0000000..0f19f8b --- /dev/null +++ b/backend/tests/test_ev_session_parse.py @@ -0,0 +1,66 @@ +"""Parser EV session z fn_planning_site_context (_ev_session_from_json). + +Bug 2026-06-13: session BEZ deadline (auto nad targetem / bez cíle) se v +parseru zahazovala (None), takže plánovač neviděl zátěž auta ani oportunismus. +Oprava: session bez deadline zůstává objektem s energy_needed_wh=0 a headroom. +""" + +import unittest + +from services.planning.db_io import _ev_session_from_json + + +class EvSessionParseTests(unittest.TestCase): + def test_none_and_empty_return_none(self) -> None: + self.assertIsNone(_ev_session_from_json(None)) + self.assertIsNone(_ev_session_from_json([])) + self.assertIsNone(_ev_session_from_json(123)) + + def test_session_without_deadline_kept_for_opportunism(self) -> None: + sess = _ev_session_from_json( + { + "target_deadline": None, + "energy_needed_wh": 0, + "headroom_wh": 18000.0, + "opportunistic_value_czk_kwh": 1.0, + } + ) + self.assertIsNotNone(sess) + assert sess is not None + self.assertIsNone(sess.target_deadline) + self.assertEqual(sess.energy_needed_wh, 0.0) + self.assertEqual(sess.headroom_wh, 18000.0) + self.assertEqual(sess.opportunistic_value_czk_kwh, 1.0) + + def test_session_with_deadline_and_need(self) -> None: + sess = _ev_session_from_json( + { + "target_deadline": "2026-06-14T05:00:00+00:00", + "energy_needed_wh": 12000.0, + "headroom_wh": 6000.0, + "opportunistic_value_czk_kwh": 1.0, + } + ) + assert sess is not None + self.assertIsNotNone(sess.target_deadline) + self.assertEqual(sess.energy_needed_wh, 12000.0) + + def test_missing_needed_defaults_zero(self) -> None: + sess = _ev_session_from_json( + {"target_deadline": None, "headroom_wh": 1000.0} + ) + assert sess is not None + self.assertEqual(sess.energy_needed_wh, 0.0) + self.assertEqual(sess.opportunistic_value_czk_kwh, 0.0) + + def test_json_string_payload(self) -> None: + sess = _ev_session_from_json( + '{"target_deadline": null, "energy_needed_wh": 0, ' + '"headroom_wh": 5000, "opportunistic_value_czk_kwh": 1.0}' + ) + assert sess is not None + self.assertEqual(sess.headroom_wh, 5000.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/db/routines/R__038_fn_ev_session_planning_json.sql b/db/routines/R__038_fn_ev_session_planning_json.sql new file mode 100644 index 0000000..6ec783f --- /dev/null +++ b/db/routines/R__038_fn_ev_session_planning_json.sql @@ -0,0 +1,76 @@ +-- jeden EV session objekt pro LP (fn_planning_site_context). +-- Vrací jsonb objekt session na daném wallboxu, nebo null::jsonb pokud session +-- není nebo nemá použitelná data (kapacita vozidla, SoC při připojení). +-- +-- KLÍČOVÝ ROZDÍL oproti dřívější inline logice (bug 2026-06-13): session se +-- NEVYŘAZUJE jen proto, že needed_wh = 0 (auto už nad targetem). Plánovač pak +-- neviděl ~6 kW zátěž auta a špatně rozvrhl baterii. Session zůstává v plánu, +-- dokud má oportunistický headroom (cena rozhodne, jestli se nabíjí) — měkký +-- cíl řeší solver dekompozicí Σ == needed − unmet + opp. +-- +-- Vyřazení (null) jen když chybí tvrdá data: +-- - žádná otevřená session na wallboxu, nebo +-- - neznámá kapacita vozidla / SoC při připojení (nelze spočítat Wh). +-- target_deadline SMÍ být NULL (žádný tvrdý cíl) — solver to zvládá +-- (deadline constraint se aplikuje jen při needed_wh > 0). + +drop function if exists ems.fn_ev_session_planning_json; + +create or replace function ems.fn_ev_session_planning_json( + p_site_id int, + p_charger_code text +) +returns jsonb +language sql +stable +as $fn$ + select case + when v.battery_capacity_kwh is null then null::jsonb + when es.soc_at_connect_pct is null then null::jsonb + else jsonb_build_object( + -- tvrdý cíl: jen pokud je nastaven deadline I cílový SoC (jinak null → + -- solver hard constraint vynechá, energy_needed_wh = 0). + 'target_deadline', case + when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null + else es.target_deadline + end, + 'energy_needed_wh', case + when es.target_deadline is null then 0::numeric + when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then 0::numeric + else greatest( + 0, + (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric + - es.soc_at_connect_pct::numeric) / 100.0 + * (v.battery_capacity_kwh * 1000) + - coalesce(es.energy_delivered_wh, 0)::numeric + ) + end, + -- headroom do 100 % od max(target, SoC při připojení): „nenabíjet" (nízký + -- target) nesmí ZVĚTŠIT oportunistickou vrstvu; auto fyzicky bere jen + -- energii nad svým aktuálním SoC. Při vypnutém oportunismu (value <= 0) + -- headroom = 0 — session zůstane v plánu, ale solver ji nebude doplňovat. + 'headroom_wh', case + when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest( + 0, + (100 - greatest( + coalesce(es.target_soc_pct, v.default_target_soc_pct, es.soc_at_connect_pct)::numeric, + es.soc_at_connect_pct::numeric + )) / 100.0 * (v.battery_capacity_kwh * 1000) + ) + else 0 + end, + 'opportunistic_value_czk_kwh', + coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) + ) + end + from ems.ev_session es + join ems.asset_ev_charger ch on ch.id = es.charger_id + left join ems.asset_vehicle v on v.id = es.vehicle_id + where es.site_id = p_site_id + and es.session_end is null + and ch.code = p_charger_code + limit 1; +$fn$; + +comment on function ems.fn_ev_session_planning_json is + 'EV session objekt pro LP (fn_planning_site_context). Session se NEvyřazuje při needed_wh=0 (auto nad targetem) — zůstává v plánu kvůli oportunistickému headroomu i jako známá zátěž. Null jen bez použitelných dat (kapacita / soc_at_connect). target_deadline smí být NULL (bez tvrdého cíle).'; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index f42e0ae..fd06f5c 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -179,113 +179,12 @@ begin where v.site_id = p_site_id and ch.code in ('ev-charger-1', 'ev-charger-2'); + -- EV session per wallbox — logika v ems.fn_ev_session_planning_json + -- (R__038): session se NEvyřazuje při needed_wh=0 (auto nad targetem), + -- zůstává v plánu kvůli oportunistickému headroomu i jako známá zátěž. v_ev := jsonb_build_array( - ( - select case - when es.target_deadline is null then null::jsonb - when v.battery_capacity_kwh is null then null::jsonb - when es.soc_at_connect_pct is null then null::jsonb - when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb - when greatest( - 0, - (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric - - es.soc_at_connect_pct::numeric) / 100.0 - * (v.battery_capacity_kwh * 1000) - - coalesce(es.energy_delivered_wh, 0)::numeric - ) <= 0 - and ( - coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0 - or (100 - greatest( - coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, - es.soc_at_connect_pct::numeric - )) <= 0 - ) then null::jsonb - else jsonb_build_object( - 'target_deadline', es.target_deadline, - 'energy_needed_wh', greatest( - 0, - (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric - - es.soc_at_connect_pct::numeric) / 100.0 - * (v.battery_capacity_kwh * 1000) - - coalesce(es.energy_delivered_wh, 0)::numeric - ), - -- headroom od max(target, SoC při připojení): „nenabíjet" (nízký - -- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může - -- fyzicky vzít jen energii nad svým aktuálním SoC. - 'headroom_wh', case - when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest( - 0, - (100 - greatest( - coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, - es.soc_at_connect_pct::numeric - )) / 100.0 * (v.battery_capacity_kwh * 1000) - ) - else 0 - end, - 'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) - ) - end - from ems.ev_session es - join ems.asset_ev_charger ch on ch.id = es.charger_id - left join ems.asset_vehicle v on v.id = es.vehicle_id - where es.site_id = p_site_id - and es.session_end is null - and ch.code = 'ev-charger-1' - limit 1 - ), - ( - select case - when es.target_deadline is null then null::jsonb - when v.battery_capacity_kwh is null then null::jsonb - when es.soc_at_connect_pct is null then null::jsonb - when coalesce(es.target_soc_pct, v.default_target_soc_pct) is null then null::jsonb - when greatest( - 0, - (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric - - es.soc_at_connect_pct::numeric) / 100.0 - * (v.battery_capacity_kwh * 1000) - - coalesce(es.energy_delivered_wh, 0)::numeric - ) <= 0 - and ( - coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) <= 0 - or (100 - greatest( - coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, - es.soc_at_connect_pct::numeric - )) <= 0 - ) then null::jsonb - else jsonb_build_object( - 'target_deadline', es.target_deadline, - 'energy_needed_wh', greatest( - 0, - (coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric - - es.soc_at_connect_pct::numeric) / 100.0 - * (v.battery_capacity_kwh * 1000) - - coalesce(es.energy_delivered_wh, 0)::numeric - ), - -- headroom od max(target, SoC při připojení): „nenabíjet" (nízký - -- target) nesmí paradoxně ZVĚTŠIT oportunistickou vrstvu; auto může - -- fyzicky vzít jen energii nad svým aktuálním SoC. - 'headroom_wh', case - when coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) > 0 then greatest( - 0, - (100 - greatest( - coalesce(es.target_soc_pct, v.default_target_soc_pct)::numeric, - es.soc_at_connect_pct::numeric - )) / 100.0 * (v.battery_capacity_kwh * 1000) - ) - else 0 - end, - 'opportunistic_value_czk_kwh', coalesce(es.opportunistic_value_czk_kwh, v.opportunistic_value_czk_kwh, 0) - ) - end - from ems.ev_session es - join ems.asset_ev_charger ch on ch.id = es.charger_id - left join ems.asset_vehicle v on v.id = es.vehicle_id - where es.site_id = p_site_id - and es.session_end is null - and ch.code = 'ev-charger-2' - limit 1 - ) + ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-1'), + ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-2') ); select ti.battery_soc_percent @@ -351,4 +250,4 @@ end; $fn$; comment on function ems.fn_planning_site_context is - 'Kontext pro planning_engine / LP (bez samotného solveru). EV session: 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 min_power_w wallboxu.'; diff --git a/docs/04-modules/ev-charging.md b/docs/04-modules/ev-charging.md index 3215b4d..fe63891 100644 --- a/docs/04-modules/ev-charging.md +++ b/docs/04-modules/ev-charging.md @@ -394,6 +394,26 @@ oportunismus). Session zůstává v plánu i po dosažení targetu, dokud má he **oportunistická vrstva není omezená deadline** (auto bývá doma dál, odjezd řeší rolling replan — rozhodnutí 2026-06-12). +### Session se NEvyřazuje při needed_wh=0 (fix 2026-06-13) + +Dřív `fn_planning_site_context` vracela `ev_sessions[e] = null`, když +`needed_wh = 0` (auto už nad targetem) **a** oportunismus byl vypnutý/headroom +nulový — a navíc úplně, když `target_deadline is null`. Druhá past byla v +Pythonu: `_ev_session_from_json` zahazovala session bez deadline. Důsledek +incidentu: aktivní plán měl `ev_sessions:0`, ač session běžela; **plánovač +neviděl ~6 kW zátěž auta** a špatně rozvrhl baterii (zbytečný večerní import). + +Oprava (R__038 `ems.fn_ev_session_planning_json` + `db_io._ev_session_from_json`): + +- Session se vyřadí (`null`) **jen** bez tvrdých dat — neznámá kapacita vozidla + nebo `soc_at_connect_pct` (nelze spočítat Wh). Jinak vždy objekt. +- **`target_deadline` smí být NULL** (žádný tvrdý cíl) — solver_v2 hard + deadline constraint aplikuje jen při `energy_needed_wh > 0`; oportunistická + vrstva běží i bez deadline. Auto nad targetem nebo bez cíle tak zůstává v + plánu jako známá zátěž i s headroomem k případnému levnému doplnění. +- `energy_needed_wh` = 0 bez deadline / cíle; headroom a opportunistic_value + beze změny (coalesce session → vozidlo). + ### Min. výkon wallboxu a účtování via-bat (2026-06-12, dev) - **`asset_ev_charger.min_power_w`** (1380 W = 6 A IEC 61851) jde přes diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 68117f9..a884b83 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -342,6 +342,47 @@ if ev_session[e].target_deadline and ev_session[e].soc_at_connect_pct is not Non # energy_needed = (default_target_soc - estimated_soc_from_session) * capacity ``` +### EV oportunismus — návrh agresivnějšího ocenění z cen (K ROZHODNUTÍ, 2026-06-13) + +**Stav (nasazeno):** měkký cíl = dekompozice `Σ(EV) == needed − unmet + opp`, +`opp ∈ [0, headroom]`, hodnota `opportunistic_value_czk_kwh` (default vozidla +**1 Kč/kWh**, konstanta). Session zůstává v plánu i bez deadline / nad targetem +(fix 2026-06-13). Filozofie v2: ceny, ne heuristiky priorit — solver srovná +oportunistický bonus s reálným nákladem nabití (slotový buy + degradace), takže +auto se opp vrstvou doplní **jen** když je energie levnější než bonus: typicky +**záporná cena** (auto vydělá / lepší než curtail) nebo velmi levné okno. + +**Problém uživatele:** „když je auto k dispozici, chci ho nabíjet hlavně při +ZÁPORNÉ ceně (vydělám), ne ať si to šetří na bůhvíkdy." Konstanta 1 Kč/kWh je +sice korektní (= ušetřené budoucí nabití, auto neumí prodat zpět), ale je tupá: +neodráží, jak levné jsou skutečně budoucí okna daného horizontu. + +**Návrh (NEnasazeno — ověřit ekonomikou + golden):** +1. **`opportunistic_value` odvozený z cen, ne konstanta.** Místo fixní 1 Kč/kWh + vzít **P50 budoucích levných nákupních oken** z `market_price_stats` + (`fn_get_predicted_price` / kvantil za OTE horizont) — „kolik bych typicky + zaplatil, kdybych to NEnabil teď". Drahá budoucnost → vyšší bonus (nabít teď + se vyplatí), levná budoucnost → nízký bonus (počká si). Spočítat v SQL + (`fn_planning_site_context` / nový `fn_ev_opportunistic_value`), ne v Pythonu. +2. **Záporná cena = agresivní strop = plné auto.** Při `buy < 0` (a v rozumné + míře i hluboce levných slotech) je nabití auta **zisk**: solver to už vidí + přes zápornou cenu v objective, ale headroom musí sahat k **100 %**, ne jen + k targetu — to dnes platí (headroom = 100 − max(target, soc_at_connect)), + takže stačí, aby opp vrstva nebyla zbytečně škrcená nízkým bonusem. Pro + záporné ceny lze bonus „zvednout" implicitně (cena sama < 0 stačí), explicitní + navýšení netřeba. +3. **Sladění s baterií (přirozeně z cen):** záporná cena → nabíjet auto i + baterii (oba mají kladnou hodnotu uložení / zisk); vysoká cena → ani auto, + ani export z baterie do sítě (degradace + ušlý budoucí prodej to zaplatí). + **Žádné explicitní priority** — správné účtování (slotová cena, degradace, + terminal/arbitrage hodnota) to vyřeší samo (pravidlo 8 / arbitrage-accounting). + +**Rozhodnout:** zda nahradit konstantu cenovým kvantilem (riziko: rozkmitá +golden ekonomiku — nutný eval na fixtures s EV session, které zatím nejsou). +Minimum, co je nasazeno bezpečně: session viditelná + headroom k plnému; bonus +zůstává konfigurovatelný per vozidlo/session. Až bude EV golden fixture, doplnit +bod 1 za flagem a změřit Kč. + ### SoC kontinuita ```python # battery_discharge = bd (W z baterie na AC sběrnici z bilance pv+gi+bd = load+bc+ge). diff --git a/docs/planning-changelog.md b/docs/planning-changelog.md index cd48a4b..3afc318 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-13 — EV session viditelná i bez deadline; reg 15 re-asert (2 bugy home-01) + +- **BUG1 (Modbus zápis EV rozbitý):** od ~22:45 UTC 12.6. nevznikl žádný telto journal řádek (ani failed), auto jelo failsafe 8 A místo plánovaných 0 A. **Příčina:** reg 15 (amps) byl write-on-change proti journalu (`fn_modbus_device_state_map`). Jakmile měl reg 15 řádek „0 verified", a plán dál chtěl 0, **nikdy nevznikl nový příkaz** — a TeltoCharge si po výpadku komunikace sám přepsal reg 15 na failsafe (reg 20) **bez journal řádku**. Verify čte zpět jen `written` řádky, takže drift 0 → 8 A nikdo neviděl ani neopravil (tichá divergence). **Fix:** reg 15 se zapisuje **každý tick** (re-asert), reg 19/20 zůstávají write-on-change (EEPROM); per-charger failsafe/timeout (V106 `asset_ev_charger.watchdog_failsafe_a` / `watchdog_comm_timeout_s`). „Zákaz nabíjení" = reg 15 = 0 (protokol rev 0.5 nemá samostatný enable registr). +- **BUG2 (plánovač slepý k autu):** aktivní plán měl `ev_sessions:0`, ač session běžela (target 70 %) → plán neviděl ~6 kW zátěž, špatně rozvrhl baterii (zbytečný večerní import). **Příčina:** `fn_planning_site_context` vracela session jako `null`, když `needed_wh=0` (auto nad targetem) i když `target_deadline is null`; navíc `_ev_session_from_json` zahazovala session bez deadline (Python). **Fix:** R__038 `fn_ev_session_planning_json` — session se vyřadí jen bez tvrdých dat (kapacita / soc_at_connect); `target_deadline` smí být NULL (solver hard constraint aplikuje jen při needed>0; oportunistická vrstva běží i bez deadline). `_ev_session_from_json` si NULL deadline ponechá. +- **Soubory:** V106, R__038, R__039 (volá helper), `services/control/outputs.py`, `services/planning/db_io.py`; testy `test_ev_write_on_change.py`, `test_ev_session_parse.py`; docs teltocharge / journal / ev-charging. +- **Ověření:** `pytest -q` 362 passed; golden replay gate 7 passed; solver_v2_eval beze změny (fixtures bez EV session — golden potvrzuje žádnou regresi na neEV cestě). +- **K ROZHODNUTÍ (nenasazeno):** agresivnější oportunistický algoritmus z cen (P50 levných oken z `market_price_stats` místo konstanty 1 Kč/kWh) — návrh v `docs/04-modules/planning.md` sekce „EV oportunismus — návrh". + ## 2026-06-13 — degradační cena dle skutečných cen packů (V103) - **Problém:** seedy nesly default 0.50 Kč/kWh u KV1/BA81/HU1 — u malých packů zabíjel mělké arbitráže, u HU1 zkresloval studii spotové smlouvy.