Merge branch 'fix/ev-teltocharge-reg15-and-session-visibility' into dev
Some checks failed
CI and deploy / migration-check (push) Failing after 7m26s
CI and deploy / deploy (push) Has been skipped

# Conflicts:
#	docs/planning-changelog.md
This commit is contained in:
Dusan Vojacek
2026-06-13 22:41:14 +02:00
14 changed files with 453 additions and 211 deletions

View File

@@ -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`.
---

View File

@@ -18,33 +18,52 @@ logger = logging.getLogger(__name__)
TELTO_REG_AMPS_TO_USE = 15 # 0 = stop, 632 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",
)

View File

@@ -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),
)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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.';

View File

@@ -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).';

View File

@@ -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.';

View File

@@ -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: 032 A (0 = po výpadku nenabíjet)
watchdog_comm_timeout_s INT NOT NULL DEFAULT 300, -- reg 19: s bez komunikace → failsafe
notes TEXT
);
```

View File

@@ -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

View File

@@ -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`).

View File

@@ -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, 632 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) | 0600 s (0 = vypnuto), default 30 | `TELTO_WATCHDOG_TIMEOUT_S` = **300** |
| 20 | R/W | Failsafe current | 0, 632 A, default 6 | `TELTO_WATCHDOG_FAILSAFE_A` = **8** |
| 15 | R/W | **Amps to use** (limit proudu) | 0 = stop, 632 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) | 0600 s (0 = vypnuto), default 30 | per charger `asset_ev_charger.watchdog_comm_timeout_s` (default **300**) |
| 20 | R/W | Failsafe current | 0, 632 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 ↔ 632 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 040 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)

View File

@@ -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).

View File

@@ -13,6 +13,14 @@ Formát: **datum (ISO)** · stručný důvod · soubory · chování / ověřen
- **Soubory:** backend/services/control/setpoints.py, test_control_export_plan_guard.py (test_neg_sell_grid_charge_not_blocked), docs/audits/planner-neg-buy-charge-not-executed-2026-06-13.md.
- **Ověření:** guard testy 47 passed; živě — záporný den → grid_w roste, SoC k cíli. Mimo solver → golden gate / solver_v2_eval beze změny.
## 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.