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). | | `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. | | `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_AMPS_TO_USE = 15 # 0 = stop, 632 A
TELTO_REG_COMM_TIMEOUT_S = 19 # watchdog: bez komunikace → failsafe TELTO_REG_COMM_TIMEOUT_S = 19 # watchdog: bez komunikace → failsafe
TELTO_REG_FAILSAFE_CURRENT_A = 20 TELTO_REG_FAILSAFE_CURRENT_A = 20
#: Výpadek EMS: po 5 min bez zápisu wallbox přejde na failsafe proud — #: Výpadek EMS: po watchdog_comm_timeout_s bez komunikace wallbox přejde na
#: auto se přes noc nabije i bez EMS (pomalu), místo aby stálo na 0 A. #: 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_TIMEOUT_S = 300
TELTO_WATCHDOG_FAILSAFE_A = 8 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. """Registry pro jeden export tick: limit proudu + watchdog konfigurace.
Write-on-change: volající VŽDY filtruje přes drop-unchanged proti **Reg 15 (amps to use) NENÍ write-on-change** — viz `_assert_amps_register`.
fn_modbus_device_state_map (poslední written/verified per registr) — Je to volatilní řídicí registr: TeltoCharge ho po výpadku komunikace sám
watchdog 19/20 se reálně zapíše jen po startu / po výpadku zařízení, přepíše na failsafe (reg 20), aniž by o tom vznikl journal řádek. Kdyby se
amps (15) jen při změně plánu. Watchdog timer TeltoCharge sytí jakákoli reg 15 dropoval proti journalu (poslední „0 verified"), EMS by tichý drift
validní Modbus komunikace (i FC3 čtení telemetrie každých 60 s), takže 0 → 8 A NIKDY nezahlédlo (verify čte zpět jen `written` řádky) a nikdy ho
periodické zápisy k udržení spojení NEJSOU potřeba (oficiální protokol, neopravilo. Proto se reg 15 re-asertuje KAŽDÝ export tick (≤ 8×/hod) —
docs/04-modules/modbus-registers-teltocharge.md). 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) a = int(current_a)
if a < 6: if a < 6:
a = 0 a = 0
return [ return [
(TELTO_REG_AMPS_TO_USE, "telto_amps_to_use", min(a, 32)), (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_COMM_TIMEOUT_S, "telto_comm_timeout_s", int(comm_timeout_s)),
(TELTO_REG_FAILSAFE_CURRENT_A, "telto_failsafe_a", TELTO_WATCHDOG_FAILSAFE_A), (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: def _current_limit_for_charger(charger_code: str, sp: ControlSetpoints) -> int:
c = (charger_code or "").strip().lower() c = (charger_code or "").strip().lower()
if c == "ev-charger-1": if c == "ev-charger-1":
@@ -74,7 +93,8 @@ async def write_ev_setpoints(
rows = await db.fetch( 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 FROM ems.asset_ev_charger ec
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1 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) unit_id = int(row["unit_id"] if row["unit_id"] is not None else 1)
current_a = _current_limit_for_charger(code, setpoints) current_a = _current_limit_for_charger(code, setpoints)
registers = _telto_setpoint_registers(current_a) registers = _telto_setpoint_registers(
# Write-on-change: poslední written/verified stav (ne jen verified) — current_a,
# zápis se nesmí opakovat každý tick, když verify čtení zaostává. 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( device_state = await _fetch_device_state_registers(
site_id, asset_id, db, asset_type="ev_charger" site_id, asset_id, db, asset_type="ev_charger"
) )
registers, skipped = _drop_registers_matching_last_verified( watchdog_regs, skipped = _drop_registers_matching_last_verified(
registers, device_state 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) logger.debug("EV setpoint [%s]: beze změny (%s A)", code, current_a)
continue continue
@@ -119,7 +154,7 @@ async def write_ev_setpoints(
host, host,
port, port,
unit_id, unit_id,
registers, to_write,
db, db,
) )
ok = await execute_modbus_commands(cmd_ids, 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", "EV setpoint [%s]: %s A (regs %s%s) -> %s",
code, code,
current_a, current_a,
[r for r, _, _ in registers], [r for r, _, _ in to_write],
f", skip {skipped}" if skipped else "", f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED", "written" if ok else "FAILED",
) )
@@ -155,7 +190,8 @@ async def write_ev_arrival_hold(
row = await db.fetchrow( 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 FROM ems.asset_ev_charger ec
JOIN ems.site_endpoint se ON se.id = ec.endpoint_id JOIN ems.site_endpoint se ON se.id = ec.endpoint_id
WHERE ec.site_id = $1 WHERE ec.site_id = $1
@@ -170,20 +206,29 @@ async def write_ev_arrival_hold(
if row is None: if row is None:
return False return False
asset_id = int(row["asset_id"]) 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( device_state = await _fetch_device_state_registers(
site_id, asset_id, db, asset_type="ev_charger" site_id, asset_id, db, asset_type="ev_charger"
) )
registers, skipped = _drop_registers_matching_last_verified( watchdog_regs, skipped = _drop_registers_matching_last_verified(
registers, device_state watchdog_regs, device_state
) )
if not registers: to_write = amps_regs + watchdog_regs
logger.info(
"EV arrival hold [%s]: 0 A už na zařízení (skip %s)",
charger_code,
skipped,
)
return True
cmd_ids = await create_modbus_commands( cmd_ids = await create_modbus_commands(
site_id, site_id,
None, None,
@@ -193,14 +238,14 @@ async def write_ev_arrival_hold(
str(row["host"]), str(row["host"]),
int(row["port"] or 502), int(row["port"] or 502),
int(row["unit_id"] if row["unit_id"] is not None else 1), int(row["unit_id"] if row["unit_id"] is not None else 1),
registers, to_write,
db, db,
) )
ok = await execute_modbus_commands(cmd_ids, db) ok = await execute_modbus_commands(cmd_ids, db)
logger.info( logger.info(
"EV arrival hold [%s]: 0 A (regs %s%s) %s", "EV arrival hold [%s]: 0 A (regs %s%s) %s",
charger_code, charger_code,
[r for r, _, _ in registers], [r for r, _, _ in to_write],
f", skip {skipped}" if skipped else "", f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED", "written" if ok else "FAILED",
) )

View File

@@ -31,12 +31,15 @@ def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
obj = json.loads(obj) obj = json.loads(obj)
if not isinstance(obj, dict): if not isinstance(obj, dict):
return None 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")) td = _parse_json_dt(obj.get("target_deadline"))
if td is None:
return None
return SimpleNamespace( return SimpleNamespace(
target_deadline=td, 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), headroom_wh=float(obj.get("headroom_wh") or 0.0),
opportunistic_value_czk_kwh=float(obj.get("opportunistic_value_czk_kwh") 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ěží Export tick běží ~8x/hod (control_export :14,:29,:44,:59 + rolling replan
~8x/hod (control_export :14,:29,:44,:59 + rolling replan */15 s exportem), */15 s exportem). **Reg 15 (amps to use) se zapisuje VŽDY** — TeltoCharge ho
ale reg 15/19/20 se smí zapsat jen při změně proti poslednímu known stavu po výpadku komunikace sám přepíše na failsafe (reg 20) bez journal řádku, a
zařízení (fn_modbus_device_state_map: nejnovější written/verified řádek kdyby byl write-on-change, EMS by tichý drift 0 → 8 A nikdy nezahlédlo
journalu per registr). Watchdog (19/20) se zapíše jednou po startu / po (verify čte zpět jen `written`). **Reg 19/20 (watchdog config, EEPROM wear)
výpadku; sytí ho i FC3 čtení telemetrie (60 s), periodické zápisy netřeba. 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 import unittest
@@ -20,6 +22,7 @@ from services.control.outputs import (
TELTO_REG_FAILSAFE_CURRENT_A, TELTO_REG_FAILSAFE_CURRENT_A,
TELTO_WATCHDOG_FAILSAFE_A, TELTO_WATCHDOG_FAILSAFE_A,
TELTO_WATCHDOG_TIMEOUT_S, TELTO_WATCHDOG_TIMEOUT_S,
_split_amps_and_watchdog,
_telto_setpoint_registers, _telto_setpoint_registers,
write_ev_arrival_hold, write_ev_arrival_hold,
write_ev_setpoints, 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(6)[0][2], 6)
self.assertEqual(_telto_setpoint_registers(40)[0][2], 32) 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): 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( out, skipped = _drop_registers_matching_last_verified(
_telto_setpoint_registers(0), _STEADY_STATE_0A watchdog, _STEADY_STATE_0A
) )
self.assertEqual(out, []) 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]) self.assertEqual(skipped, [19, 20])
def test_empty_state_after_outage_writes_full_triple(self) -> None: def test_empty_state_after_outage_keeps_19_20(self) -> None:
out, skipped = _drop_registers_matching_last_verified( _, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0))
_telto_setpoint_registers(0), {} out, skipped = _drop_registers_matching_last_verified(watchdog, {})
) self.assertEqual([r for r, _, _ in out], [19, 20])
self.assertEqual([r for r, _, _ in out], [15, 19, 20])
self.assertEqual(skipped, []) self.assertEqual(skipped, [])
class _FakeDB: class _FakeDB:
"""Jen řádky chargeru; journal funkce se patchují v modbus_journal.""" """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 = { self.row = {
"asset_id": 7, "asset_id": 7,
"code": "ev-charger-1", "code": "ev-charger-1",
"host": "172.16.1.16", "host": "172.16.1.16",
"port": 502, "port": 502,
"unit_id": 1, "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]: async def fetch(self, query: str, *args: object) -> list[dict]:
@@ -105,9 +116,9 @@ class _FakeDB:
raise AssertionError(f"unexpected fetchval: {query}") raise AssertionError(f"unexpected fetchval: {query}")
class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase): class WriteEvSetpointsTests(unittest.IsolatedAsyncioTestCase):
async def _run( 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]: ) -> tuple[AsyncMock, AsyncMock]:
create = AsyncMock(return_value=[1, 2, 3]) create = AsyncMock(return_value=[1, 2, 3])
execute = AsyncMock(return_value=True) execute = AsyncMock(return_value=True)
@@ -120,13 +131,17 @@ class WriteEvSetpointsOnChangeTests(unittest.IsolatedAsyncioTestCase):
patch.object(journal, "create_modbus_commands", create), patch.object(journal, "create_modbus_commands", create),
patch.object(journal, "execute_modbus_commands", execute), 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 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, execute = await self._run(_STEADY_STATE_0A, ev1_a=0)
create.assert_not_awaited() create.assert_awaited_once()
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_plan_change_writes_only_amps(self) -> None: async def test_plan_change_writes_only_amps(self) -> None:
create, execute = await self._run(_STEADY_STATE_0A, ev1_a=16) 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)]) self.assertEqual([(r, v) for r, _, v in registers], [(15, 16)])
execute.assert_awaited_once() 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) create, execute = await self._run({}, ev1_a=0)
registers = create.await_args.args[8] registers = create.await_args.args[8]
self.assertEqual([r for r, _, _ in registers], [15, 19, 20]) self.assertEqual([r for r, _, _ in registers], [15, 19, 20])
execute.assert_awaited_once() 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( async def _run(
self, device_state: dict[int, int] self, device_state: dict[int, int]
) -> tuple[bool, AsyncMock, AsyncMock]: ) -> 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] ok = await write_ev_arrival_hold(1, "ev-charger-1", _FakeDB()) # type: ignore[arg-type]
return ok, create, execute 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) ok, create, execute = await self._run(_STEADY_STATE_0A)
self.assertTrue(ok) self.assertTrue(ok)
create.assert_not_awaited() registers = create.await_args.args[8]
execute.assert_not_awaited() 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( ok, create, execute = await self._run(
{ {
TELTO_REG_AMPS_TO_USE: 16, TELTO_REG_AMPS_TO_USE: 16,
@@ -176,6 +203,7 @@ class WriteEvArrivalHoldOnChangeTests(unittest.IsolatedAsyncioTestCase):
) )
self.assertTrue(ok) self.assertTrue(ok)
registers = create.await_args.args[8] 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)]) self.assertEqual([(r, v) for r, _, v in registers], [(15, 0)])
execute.assert_awaited_once() 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 where v.site_id = p_site_id
and ch.code in ('ev-charger-1', 'ev-charger-2'); 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( v_ev := jsonb_build_array(
( ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-1'),
select case ems.fn_ev_session_planning_json(p_site_id, 'ev-charger-2')
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
)
); );
select ti.battery_soc_percent select ti.battery_soc_percent
@@ -351,4 +250,4 @@ end;
$fn$; $fn$;
comment on function ems.fn_planning_site_context is 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, phases INT DEFAULT 3,
connector_count INT DEFAULT 1, connector_count INT DEFAULT 1,
schedulable BOOLEAN DEFAULT true, 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 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 **oportunistická vrstva není omezená deadline** (auto bývá doma dál, odjezd
řeší rolling replan — rozhodnutí 2026-06-12). ř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) ### 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 - **`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) ## EV wallbox (TeltoCharge)
`write_ev_setpoints` (každý export tick) a `write_ev_arrival_hold` (po detekci `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) příjezdu EV) zapisují registry **15** (amps to use), **19** (comm timeout) a
a **20** (failsafe 8 A) — vždy přes journal (`asset_type = 'ev_charger'`). **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` - **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 nefiltruje podle `asset_type` a registry 15/19/20 jsou dle protokolu R/W
(čtou se zpět standardní FC 3 větví). (čtou se zpět standardní FC 3 větví).
- **Write-on-change:** před zápisem se registry filtrují proti - **Reg 15 (amps) se zapisuje KAŽDÝ tick** (re-asert), **NE write-on-change.**
**`ems.fn_modbus_device_state_map`** (nejnovější řádek journalu per registr; Incident 2026-06-13: TeltoCharge si po výpadku komunikace sám přepíše reg 15
hodnota jen pro stav `written`/`verified`). Shodná hodnota ⇒ zápis se na failsafe (reg 20) bez journal řádku; write-on-change proti journalu
přeskočí. Na rozdíl od `fn_modbus_last_verified_map` (Deye drop-unchanged) (poslední „0 verified") by tichý drift **0 → 8 A** nikdy nezahlédlo (verify
nečeká na verify — `written` stačí, takže pomalý/neúspěšný verify read čte zpět jen `written`) a nikdy neopravilo. Re-asert každý tick drift opraví
nevede k opakovaným zápisům každý tick (EEPROM wear). Nejnovější řádek a drží verify jobu čerstvý `written` reg-15 řádek. Reg 15 je volatilní řídicí
`failed`/`mismatch` ⇒ registr v mapě chybí ⇒ po výpadku zařízení se registr (ne EEPROM).
konfigurace (vč. watchdog 19/20) obnoví jedním zápisem. - **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 - **Mismatch po 3 pokusech NEpřepíná SELF_SUSTAIN** — fallback režim je Deye
politika (`asset_type = 'inverter'`); u wallboxu zůstane řádek `mismatch` politika (`asset_type = 'inverter'`); u wallboxu zůstane řádek `mismatch`
+ Discord (`notify_modbus_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 | | 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 | | 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 | | 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 | `TELTO_WATCHDOG_TIMEOUT_S` = **300** | | 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 | `TELTO_WATCHDOG_FAILSAFE_A` = **8** | | 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 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 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ě). 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 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é `*/15` s exportem).
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:
- **reg 15** se zapíše jen při změně plánovaného proudu (0 ↔ 632 A) — to je - **reg 15 (amps to use) se zapisuje při KAŽDÉM ticku** (`write_ev_setpoints`
legitimní zápis; i `write_ev_arrival_hold`). **Důvod (incident 2026-06-13):** TeltoCharge si
- **reg 19/20** se zapíší jednou po nasazení / po výpadku zařízení (nejnovější po výpadku komunikace sám přepíše reg 15 na failsafe (reg 20) — bez journal
řádek `failed`/`mismatch` ⇒ registr v mapě chybí ⇒ znovu se zapíše) a pak řádku. Kdyby byl reg 15 write-on-change proti journalu (poslední
už nikdy, dokud se hodnota nezmění; „0 verified"), EMS by tichý drift **0 → 8 A** na zařízení **NIKDY
- čekání na verify **neblokuje** skip — `written` (TCP ack) stačí, mismatch nezahlédlo** (verify čte zpět jen `written` řádky) a nikdy ho neopravilo:
z verify stav mapy zneplatní a vynutí nový zápis. 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 Protokol definuje timeout jako *„if no **valid communication** is present
after a configurable time interval…"* — timer resetuje **jakákoli** validní after a configurable time interval…"* — timer resetuje **jakákoli** validní
Modbus komunikace s unit ID wallboxu, **včetně FC 3 čtení**. Telemetrie čte 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 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 **periodické zápisy k udržení spojení nejsou potřeba**. Failsafe (reg 20
8 A, reg 20 „max allowed current on comm timeout") nastane až po 5 min bez „max allowed current on comm timeout") nastane až po `watchdog_comm_timeout_s`
jakéhokoli pollingu = skutečný výpadek EMS; auto se pak přes noc dobije bez jakéhokoli pollingu = skutečný výpadek EMS.
pomalu místo stání na 0 A.
**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) ## 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 # 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 ### SoC kontinuita
```python ```python
# battery_discharge = bd (W z baterie na AC sběrnici z bilance pv+gi+bd = load+bc+ge). # 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. - **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. - **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) ## 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. - **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.