311 Commits

Author SHA1 Message Date
Dusan Vojacek
e1ebcc65af merge dev → main: EV event-driven replan na odjezd (shodí fantomovou EV alokaci po odjezdu auta)
All checks were successful
CI and deploy / migration-check (push) Successful in 46s
CI and deploy / deploy (push) Successful in 1m12s
2026-06-17 15:57:59 +02:00
Dusan Vojacek
ab8ddf1fdf feat(ev): event-driven replan i na odjezd EV (ne jen příjezd)
All checks were successful
CI and deploy / migration-check (push) Successful in 7m17s
CI and deploy / deploy (push) Has been skipped
Odjezd auta (≠available → available) teď spouští okamžitý rolling replan
+ export, symetricky k příjezdu — místo čekání na */15 tick. Řeší stale
plán spočítaný těsně před odjezdem, který držel fantomovou EV alokaci
(~4–11 kW do už odjetého auta). Session už zavřela fn_ev_session_transition
synchronně v poll smyčce, takže replan vidí 'žádná session' a alokaci shodí.

Replan i pozorování jízdy každý ve vlastním try+conn (pád solveru ani spící
auto se navzájem neshodí). +2 regresní testy, +docs (changelog, ev-charging).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:38:13 +02:00
Dusan Vojacek
ce30dbd4a4 merge dev → main: control fix reg 108 PV_SURPLUS charge intent + backlog use-case
All checks were successful
CI and deploy / migration-check (push) Successful in 10m13s
CI and deploy / deploy (push) Successful in 1m0s
fix(control): reg 108 sleduje charge intent v PV_SURPLUS (BA81 nenabíjelo levné ráno).
365 passed, control-only (golden gate beze změny). Všechny Deye lokality.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:45:36 +02:00
Dusan Vojacek
daf7ed4d4b fix(control): reg 108 v PV_SURPLUS sleduje charge intent (BA81 nenabíjelo levné ráno)
deye_battery_charge_discharge_amps: v PASSIVE+PV_SURPLUS reg 108 = max když plán
chce nabíjet (bat_w>0) místo tvrdé 0; baterka nabere co zvládne, přebytek nad
nabíjecí rychlost do sítě. + kalibrace: SoC u maxima → dojet na 100% (BMS). Sell
beze změny. Vědomě přepsán test starého chování. 365 passed. Všechny Deye lokality.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:32:01 +02:00
Dusan Vojacek
17147ca412 docs(backlog): export-constrained lokalita — curtailment-min test use-case
Tier 3 test: malý export limit + velký instal → ověřit, že MILP drží baterce
rezervu na polední peak místo naivního plnění ráno. Závislé na PV forecast review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:47:20 +02:00
Dusan Vojacek
c27e1cbe6d merge dev → main: aktivační migrace home-01 (notify/pool/start-penalty V111-113)
All checks were successful
CI and deploy / migration-check (push) Successful in 22s
CI and deploy / deploy (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:20:32 +02:00
Dusan Vojacek
1479572569 feat(activate): home-01 — notify (V111) + pool control (V112) + EV start penalty (V113)
Operační aktivace nasazených featur přes Flyway:
- V111: asset_vehicle.presence_nudge_enabled=true (tesla-my) → proaktivní nudge
- V112: signal_route POOL_PUMP_ON → Shelly + asset_pool_pump.schedulable=true
- V113: asset_ev_charger.planner_ev_start_penalty_czk=0.5 (anti-fragmentace, laditelné)
Geofence (env EV_GEOFENCE_ARRIVAL_OBS_ENABLED) si nastaví uživatel na serveru.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:20:32 +02:00
Dusan Vojacek
b052c9c0e7 merge dev → main: EV Fix B (anti-fragmentace 3f floor) + geofence arrival + proaktivní notifikace
All checks were successful
CI and deploy / migration-check (push) Successful in 40s
CI and deploy / deploy (push) Successful in 1m18s
- feat(planner): EV 3f power floor (aktivní, korektnost) + block-start penalta (V108, default 0)
- feat(ev): geofence arrival trigger (V109, default-off)
- feat(ev): proaktivní 'píchni auto' notifikace (V110, default-off)
golden gate + full suite 363 passed; živě ověřeny constraint name + phases sloupec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:01:08 +02:00
Dusan Vojacek
c03f9dd9d6 feat(ev): proaktivní notifikace 'píchni auto' (default-off)
job ev_presence_notify + fn_ev_presence_nudge_due (SQL-first rozhodnutí+dedup);
asset_vehicle.presence_nudge_enabled default false=inertní (V110). Worktree agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:55:17 +02:00
Dusan Vojacek
fc6d9833a7 feat(ev): geofence arrival trigger (default-off)
ev_vehicle_obs.trigger += 'geofence_arrival' (V109); presence cesta zapíše příjezd
i bez píchnutí (za flagem EV_GEOFENCE_ARRIVAL_OBS_ENABLED, default OFF); fn_ev_build_trips
páruje. Constraint name ověřen živě. Worktree agent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:55:17 +02:00
Dusan Vojacek
a32839bf67 feat(planner): EV anti-fragmentace + 3f power floor (Fix B)
3f floor (phases>=3 → 6A×fáze×230 ≈4140W, ruší 1f trickle) + block-start penalta
(asset_ev_charger.planner_ev_start_penalty_czk V108, default 0=no-op). Golden gate
zelená (363 passed). Postaveno paralelním worktree agentem, zvalidováno sériově.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:55:17 +02:00
Dusan Vojacek
fd7012e23d merge dev → main: EV tolerance 'dost dobré' + pool control Phase 1 (inertní)
All checks were successful
CI and deploy / migration-check (push) Successful in 30s
CI and deploy / deploy (push) Successful in 1m20s
- fix(planner): EV needed_wh=0 v toleranci targetu (V107) — konec mini-dobíjení/cyklování
- feat(pool): řízení bazénu Phase 1 (fn_pool_schedule_slot/control_tick) — inertní
  dokud schedulable=false + chybí signal_route; aktivace provozně

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:25:40 +02:00
Dusan Vojacek
a9a6a88a88 fix(planner): EV tolerance 'dost dobré' — konec honění posledních % do 100 %
needed_wh=0 když live_soc >= least(target,99) - charge_done_tolerance_pct (V107,
default 3 p.b.). Effective target zastropovaný na 99 (clamp) → bez věčného
mini-dobíjení a cyklování nabíječky. Ověřeno živě: session #6 needed_wh 1329→0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:23:38 +02:00
Dusan Vojacek
f70111f44b feat(pool): řízení bazénu Phase 1 — nejlevnější okno + dump-load (bez solveru)
fn_pool_schedule_slot: nejlevnější souvislé okno denního runtime budgetu
(fn_pool_daily_runtime_min) z vw_site_effective_price + dump-load při sell<=0.
fn_pool_control_tick: každých 15 min spočte stav a zařadí POOL_PUMP_ON (jen když
existuje signal_route → bezpečné před aktivací). lifespan job pool_control.
Shelly přes signal_service, žádné Modbus. Bazál odečet (R__003) se tím stává
správným (řízená+plánovaná zátěž). Aktivace provozně: daily_runtime_min=480,
schedulable, signal_route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:00:49 +02:00
Dusan Vojacek
3e369606b4 merge dev → main: EV živé SoC fix (phantom okna) + amps round() + docs
All checks were successful
CI and deploy / migration-check (push) Successful in 33s
CI and deploy / deploy (push) Successful in 1m23s
- fix(planner): živé EV SoC z integrálu power_w (R__038) — needed_wh 18750→1329,
  konec phantom 11 kW oken; reg 39 counter rozbitý → integrál power_w
- fix: watts_to_amps round() (11 kW = 16 A místo 15 A)
- docs: arbitráž sell strana, changelog hardcoded wallbox kódy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:31:18 +02:00
Dusan Vojacek
8ffe5460f1 fix(planner): živé EV SoC z integrálu power_w — konec phantom 11 kW oken
needed_wh i headroom z live_soc (soc_at_connect + integrál power_w), ne ze
zamrzlého soc_at_connect. energy_delivered_wh se během session nikdy nezapisoval
(→ needed konstantní, plánovač slepý k pokroku), counter energy_kwh (Telto reg 39)
je rozbitý (17.4 kWh nabito → counter 0.18). Nový fn_ev_session_delivered_wh
integruje power_w (dt cap 120 s), clamp 99 %, fallback drží staré chování bez
telemetrie. Ověřeno živě: needed_wh 18750→1329, live_soc 97.9 %.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:33:08 +02:00
Dusan Vojacek
1ef8630302 fix: watts_to_amps round() misto int() — 11 kW = 16 A (ne 15 A / -6%)
All checks were successful
CI and deploy / migration-check (push) Successful in 16s
CI and deploy / deploy (push) Has been skipped
11000 W / (3x230) = 15.94 A; int() useklo na 15 A (~10.35 kW), round da
spravnych 16 A (~11 kW). Strop 32 A drzi horni mez. 74 control testu zelenych.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:42:30 +02:00
Dusan Vojacek
f726188ec9 docs: arbitráž — sell strana marginální cena vs strop exportu (proč baterie nedrží na špičku)
All checks were successful
CI and deploy / migration-check (push) Successful in 31s
CI and deploy / deploy (push) Has been skipped
Zrcadlo sekce 3 (buy: min(buy) != cena zásoby) na prodejní straně: baterie se
nevyprodá do jednoho slotu (strop 13.5 kW = 3.4 kWh/15min), rozloží se přes
více slotů s klesající cenou. Rozhodnutí drzet vs vybit = proti MARGINÁLNÍ
ceně (nejnizsi pouzity slot), ne spicce. Konkrétní příklad večer 2026-06-14
+ caveat terminal value za horizontem (jeden skalár, ne marginální).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:46:42 +02:00
Dusan Vojacek
87a4f47666 docs: changelog hotfix hardcoded wallbox kody (oslepnuti planovace po rename)
All checks were successful
CI and deploy / migration-check (push) Successful in 32s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:31:52 +02:00
Dusan Vojacek
8d23eb7dce Merge hotfix: planovac hardcoded wallbox kody (oslepnuti po rename)
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Has been skipped
2026-06-14 11:26:41 +02:00
Dusan Vojacek
1060bad57b HOTFIX: planovac oslepl k autu po prejmenovani wallboxu (hardcoded kody)
fn_planning_site_context (R__039) a fn_load_planning_slots_full (R__063) mely
natvrdo 'ev-charger-1/2' a 'deye-main'. Uzivatel prejmenoval wallboxy na
'vt-ev-charger-1/2' -> ctx.vehicles=[], ev_sessions=[null,null], ev1/ev2_connected
vzdy false -> planovac nevidel auto -> ZADNE nabijeni ani v zapornych cenach
(Tesla 70%, potrebuje 90% do Po 7:00, okno -0.32 Kc ve 13:45 nevyuzite).

Fix: vyber wallboxu DYNAMICKY podle site_id, ev1=nejnizsi ch.id, ev2=druhy
(stabilni, odolne prejmenovani). Inverter pro gen_cutoff pres controllable=true
misto code='deye-main'. Konzistentni R__039 (vehicles order by id, sessions
dynamicke kody) + R__063 (ev1/ev2 connected). Pure SQL, 363 testu zelenych.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:26:41 +02:00
Dusan Vojacek
74e156514a docs: nocni read-only backlog (workflow 16 agentu) — co chybi/dotahnout/zlepsit
Some checks failed
CI and deploy / migration-check (push) Failing after 16m35s
CI and deploy / deploy (push) Has been cancelled
Prioritizovany backlog (TL;DR, Tier 1-3, rozdelane, rizika, dozraje casem).
Nejzavaznejsi nalez: golden gate NEbeztzi v CI (deploy.yml = jen migrace+validate+
deploy) -> ochrana solveru je v nasazeni iluzorni. K review uzivatelem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:08:31 +02:00
Dusan Vojacek
5bfea4457b docs+re-trigger: CI gotcha prazdny commit/duplicitni SHA netriggeruje deploy; re-trigger Faze 0 (V106)
All checks were successful
CI and deploy / migration-check (push) Successful in 37s
CI and deploy / deploy (push) Has been skipped
Faze 0 (battery guard + EV reg15/session, V106) zustala na serveru nenasazena
(V105) — prazdny re-trigger commit se neprojevil. Tento neprazdny commit na main
(unikatni SHA, ref=main) spusti realny deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:20:35 +02:00
Dusan Vojacek
2590eeb0a3 re-trigger Faze 0 deploy (migration-check spadl na transientni DB connection, ne na migracich)
All checks were successful
CI and deploy / deploy (push) Has been skipped
CI and deploy / migration-check (push) Successful in 22s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:01:45 +02:00
Dusan Vojacek
8452b34b25 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
2026-06-13 22:41:14 +02:00
Dusan Vojacek
521a3653d3 Faze 0A: battery guard carve-out — neblokovat import na nabiti pri zaporne cene
_apply_export_plan_guard / _build_setpoints: kdyz slot CHARGE / importuje na
nabiti baterie (grid_sp>0 & bat>0), guard vrati sp beze zmeny a export_ban se
nenastavi. Opravuje, ze se baterie nedobila v zapornych cenach (CHARGE+17kW
prekloplen na PASSIVE -> Deye nenabijel ze site). Diagnoza: agent a599eecc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:40:18 +02:00
Dusan Vojacek
d81a150014 fix(planner): EV session viditelna i bez deadline / nad targetem (BUG2)
Zivy incident home-01: aktivni plan mel ev_sessions:0, ac session bezela
(target 70 %). Planovac neviděl ~6 kW zatez auta a spatne rozvrhl baterii
(zbytecny vecerni import).

Root cause (dve pasti):
- fn_planning_site_context vracela session jako null, kdyz needed_wh=0
  (auto nad targetem) i kdyz target_deadline is null.
- _ev_session_from_json (Python) zahazovala session bez deadline.

Fix:
- R__038 fn_ev_session_planning_json: session se vyradi (null) JEN bez tvrdych
  dat (kapacita vozidla / soc_at_connect). target_deadline smi byt NULL --
  solver hard deadline constraint aplikuje jen pri needed>0; oportunisticka
  vrstva bezi i bez deadline. Auto nad targetem zustava v planu jako znama
  zatez i s headroomem k levnemu doplneni. R__039 vola helper (deduplikace
  dvou inline poddotazu, SQL-first).
- _ev_session_from_json si NULL deadline ponecha (energy_needed_wh default 0).
- testy test_ev_session_parse.py; docs ev-charging + planning-changelog;
  CLAUDE.md funkce.

Navrh agresivnejsiho oportunistickeho algoritmu (P50 levnych oken z
market_price_stats misto konstanty 1 Kc/kWh) -- NEnasazeno, k rozhodnuti,
sepsano v docs/04-modules/planning.md (EV oportunismus); riziko regrese
golden ekonomiky, nutny EV fixture + eval.

Overeni: pytest -q 362 passed; golden replay gate 7 passed; solver_v2_eval
beze zmeny (fixtures bez EV session).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:03:27 +02:00
Dusan Vojacek
54288ee2fd fix(modbus): reg 15 re-asert kazdy tick + per-charger failsafe (BUG1)
Zivy incident home-01 (TeltoCharge .16): od ~22:45 UTC 12.6. nevznikl zadny
telto journal radek (ani failed), auto jelo failsafe 8 A misto planovanych 0 A.

Root cause: reg 15 (amps) byl write-on-change proti journalu
(fn_modbus_device_state_map). Jakmile mel reg 15 radek "0 verified" a plan
dal chtel 0, NIKDY nevznikl novy prikaz -- a TeltoCharge si po vypadku
komunikace sam prepsal reg 15 na failsafe (reg 20) BEZ journal radku. Verify
cte zpet jen 'written' radky, takze tichy drift 0 -> 8 A nikdo nevidel ani
neopravil.

- reg 15 (amps to use) se zapisuje VZDY (re-asert) -- volatilni ridici
  registr, ne EEPROM; drzi verify jobu cerstvy written radek -> drift se
  zachyti a hned opravi. _split_amps_and_watchdog odděluje 15 od 19/20.
- reg 19/20 (watchdog config, EEPROM) zustavaji write-on-change.
- per-charger failsafe/timeout: asset_ev_charger.watchdog_failsafe_a /
  watchdog_comm_timeout_s (V106; default 8 A / 300 s). "Zakaz nabijeni" =
  reg 15 = 0 (protokol rev 0.5 nema samostatny enable registr).
- testy test_ev_write_on_change.py; docs teltocharge + journal + data-model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:03:11 +02:00
Dusan Vojacek
6e89b044f5 open questions: Tesla target bug vyřešen (03b7396)
All checks were successful
CI and deploy / migration-check (push) Successful in 1m24s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 08:29:53 +02:00
Dusan Vojacek
03b7396676 Tesla charge_limit_soc = strop, ne cíl session
All checks were successful
CI and deploy / migration-check (push) Successful in 24s
CI and deploy / deploy (push) Successful in 1m14s
Patch po příjezdu přepisoval target_soc_pct limitem auta (LFP 100 %) a
zahazoval kaskádu fn_ev_session_defaults (default vozidla 30 %) — auto by
se v noci tlačilo do plna ze sítě proti vůli majitele (session #2 dnes).
Nově se target snižuje jen pokud je limit auta POD ním;
fn_tesla_arrival_context vrací i target_soc_pct session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:00:13 +02:00
Dusan Vojacek
c635f8f5dc open question: Tesla charge_limit přepisuje session target (noční nález)
All checks were successful
CI and deploy / migration-check (push) Successful in 22s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:57:04 +02:00
Dusan Vojacek
7e9cd933b6 V105: WB2 mimo EMS (endpoint disabled + schedulable=false), reaktivace dokumentovaná
All checks were successful
CI and deploy / migration-check (push) Successful in 13s
CI and deploy / deploy (push) Successful in 55s
Dle dotačních podmínek WB2 řídí elektroměr; EMS na něj přestane sahat
(poll i zápisy) — uvolní RS485 bránu. Zpětné zapnutí = 2 UPDATE
(komentář migrace + teltocharge doc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:47:38 +02:00
Dusan Vojacek
f81c2e4b71 docs TUV: hydraulika nádrže — krátké DHW běhy, bulk přes topný okruh do spodku, T_mid nutné, charakterizační test
All checks were successful
CI and deploy / migration-check (push) Successful in 18s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:39:40 +02:00
Dusan Vojacek
c601438eea HOTFIX deploy: grant na vw_telemetry_heat_pump_15m_7d patří jen do R__101
All checks were successful
CI and deploy / migration-check (push) Successful in 17s
CI and deploy / deploy (push) Successful in 5m35s
R__072 běží před R__101 (abecední pořadí repeatables) — grant na ještě
neexistující view shodil 2 deploye. Konvence: grant ve vlastním souboru view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:34:29 +02:00
Dusan Vojacek
b168618332 V104 úklid stale pending + TUV design: stavový automat, spirála dump-load, legionella
Some checks failed
CI and deploy / migration-check (push) Successful in 15s
CI and deploy / deploy (push) Failing after 24s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:31:24 +02:00
Dusan Vojacek
710283f784 Merge commit 'b087825' into dev 2026-06-13 00:30:34 +02:00
Dusan Vojacek
b08782525e fix(modbus): zadne vecne pending v journalu + flock timeout + EV poll backoff
Zivy incident home-01 (TeltoCharge .16): zapis 15/19-20 koncil failed
s prazdnym error_msg, nebo zustal trvale pending a zablokoval exportni ticky.

- _gateway_exclusive: neblokujici flock s deadline (EMS_MODBUS_FLOCK_TIMEOUT_S,
  default 20 s) -> GatewayLockTimeout misto starvation bez limitu
- execute_modbus_commands: invariant written/failed + neprazdny error_msg
  (str(e) or repr(e)); safety net pres BaseException (CancelledError, chyba DB);
  journal update mimo retry cyklus zarizeni; force_disconnect bez zamku brany
- telemetry poll_ev_chargers: po 3 selhanich backoff 5 min per (host,port,unit)
  - mrtvy unit_id drzi branu 4x8=32 s z kazde minuty
- testy backend/tests/test_modbus_execute_failsafe.py; docs
  modbus-command-journal.md (sekce Robustnost zapisu + konfigurace)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:17:04 +02:00
Dusan Vojacek
8882fa0c91 Dashboard: TUV křivka napojená na skutečnou telemetrii TČ
Some checks failed
CI and deploy / migration-check (push) Successful in 17s
CI and deploy / deploy (push) Failing after 46s
tuv_actual_c byl od vzniku grafu placeholder (null), TUV chart nikdy
neukazoval data. Nové view vw_telemetry_heat_pump_15m_7d (15min agregace,
R__101 + grant R__072) a plnění slotů v useDashboardData. Teploty avg přes
přítomné řádky (idle-skip ok — není to výkon).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:09:10 +02:00
Dusan Vojacek
fb9d0f107a journal API: + asset_code a error_msg — diagnostika selhaných zápisů bez SSH
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 1m14s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:48:32 +02:00
Dusan Vojacek
042581681b docs: design řízení TUV (EHS set-point posun + spirály) s NZÚ TČ+FV compliance mappingem
All checks were successful
CI and deploy / migration-check (push) Successful in 21s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:44:19 +02:00
Dusan Vojacek
a742c295b7 V103: degradační cena dle cen packů — KV1/BA81 0.50→0.25, HU1 →0.15
All checks were successful
CI and deploy / migration-check (push) Successful in 24s
CI and deploy / deploy (push) Successful in 55s
Calendar-bound filozofie (majitel): parametr = šumový floor, ne plná cena
cyklu. Odemyká mělčí arbitráže na malých packech. Detail v changelogu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:27:57 +02:00
Dusan Vojacek
f531214dac Merge branch 'worktree-agent-a85e5077c297a63df' into dev
All checks were successful
CI and deploy / migration-check (push) Successful in 18s
CI and deploy / deploy (push) Successful in 1m14s
2026-06-12 22:23:46 +02:00
Dusan Vojacek
7decfebdbd TeltoCharge write-on-change: zápis jen při změně hodnoty (EEPROM wear)
Wallbox dostával zápisy 15/19/20 každý export tick (~8x/hod: control_export
:14,:29,:44,:59 + rolling replan */15 s exportem), protože drop-unchanged
stál na fn_modbus_last_verified_map — dokud verify čtení nedoběhlo/selhalo,
mapa byla prázdná a celá trojice se psala pořád dokola. write_ev_arrival_hold
navíc psal trojici nepodmíněně při každém píchnutí kabelu (docstring lhal).

- nová ems.fn_modbus_device_state_map (R__100): nejnovější řádek journalu
  per registr, hodnota jen pro written/verified; failed/mismatch => registr
  chybí => po výpadku se konfigurace obnoví jedním zápisem
- write_ev_setpoints + write_ev_arrival_hold filtrují přes tuto mapu:
  reg 15 jen při změně plánu, watchdog 19/20 jednou po startu/po výpadku
- verify job EV chargery ověřuje už dnes (fn_modbus_written_command_ids bez
  filtru asset_type); registry 15/19/20 jsou dle oficiálního protokolu R/W
- watchdog Telto sytí jakákoli validní komunikace vč. FC3 čtení telemetrie
  (60 s << 300 s) — periodické zápisy k udržení spojení nejsou potřeba,
  failsafe 8 A nastane jen při skutečném výpadku EMS
- testy: tests/test_ev_write_on_change.py (drop, setpoints, arrival hold)
- docs: modbus-registers-teltocharge.md (sekce Zápis už není "NEimplementováno",
  R/W tabulka, watchdog sémantika), modbus-command-journal.md (sekce EV
  wallbox), CLAUDE.md (fn_modbus_device_state_map)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:21:59 +02:00
Dusan Vojacek
55deae984e HU1 studie: --pure-bess a --rt-eff flagy + výsledky čistého BESS
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 1m5s
Zadání majitele: čistý BESS bez odběru, export povolen, varianta bez ztrát.
Výsledky (365 dní vč. zimy 25/26): arbitráž na spotu +129.4 tis. Kč/rok
(η=1.0) / +110.0 tis. (η=0.9); konzervativně (spready −30 % + deg 0.5)
+63.4 tis. Na fixní smlouvě jen ~24–35 tis. → spot je podmínka smysluplnosti
BESS. Léto ~378 Kč/den, zima ~308.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 22:11:41 +02:00
Dusan Vojacek
a889950eba Merge commit '826c776'
All checks were successful
CI and deploy / migration-check (push) Successful in 27s
CI and deploy / deploy (push) Has been skipped
2026-06-12 22:00:45 +02:00
Dusan Vojacek
826c776c34 HU1: realistická studie přechodu na spot (den-po-dni solver_v2, 2 roky OTE)
scripts/harness/hu1_realistic_eval.py — realistická simulace BESS 128 kWh/36 kW
na spotu místo perfect hindsight: D−1 plán přes solve_dispatch_v2 (informační
množina = ceny zítřka z OTE D−1 13:30), SoC řetězené mezi dny, parametry
baterie/grid z DB site 5 (fn_planning_site_context). Scénáře fix/spot ×
bez/s baterií, Kč/den po měsících a sezónách, roční projekce, citlivosti
(degradace 0.15/0.5/1.0, spread compression −30 %), GAP vs 7denní hindsight.
Varianty běží paralelně (ProcessPoolExecutor). HU1 nemá telemetrii →
parametrizovaný průmyslový odběr (konstanty v hlavičce, přepsat čísly
od majitele); fixní cena = proxy BA81 (--fix-buy).

Výsledky (788 dní 2024-04-14…2026-06-12, 2 zimy): D−B (přechod na spot
s baterií) −163,6 tis. Kč/rok base, konzervativně −110,1 tis.; léto −629,
zima −254 Kč/den, nejhorší měsíc (12/2024) −41 Kč/den — stále úspora.
GAP realistic vs hindsight ≈ 0 (ceny jsou D−1 známé) → dřívější horní mez
byla nadhodnocená sezónností, ne neznalostí budoucnosti.

Doc: docs/studies/hu1-spot-realistic.md (generuje skript, opakovatelné);
README harness doplněn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:56:03 +02:00
Dusan Vojacek
74dbe87018 V102: TČ hp-samsung patří home-01, ne KV1 — kořen mrtvé TČ telemetrie
All checks were successful
CI and deploy / migration-check (push) Successful in 33s
CI and deploy / deploy (push) Successful in 1m10s
Seed přiřadil asset_heat_pump KV1; V096/V101 s where code='home-01' byly
tiché no-opy a endpoint .17 se nikdy nezapsal. KV1 navíc plánoval s TČ,
které nemá. Endpoint TČ nastaven na 172.16.1.17:502 unit 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:51:50 +02:00
Dusan Vojacek
26013e229b fix: defrost MIM — 0xFF znamená OFF (dopatch k e2688bb)
All checks were successful
CI and deploy / migration-check (push) Successful in 14s
CI and deploy / deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:41:01 +02:00
Dusan Vojacek
e2688bb899 TČ ŽIVÉ: MIM adresa 5 (V101), fix dekódování defrost (0xFF = off)
Some checks failed
CI and deploy / migration-check (push) Successful in 18s
CI and deploy / deploy (push) Has been cancelled
Rozchozeno po třech vrstvách: protokol převodníku Modbus TCP to RTU
(byl None), parita EVEN, adresa MIM 5 (seed měl 1). První živé čtení:
EHS typ 115, comm ready, mode heat, prostor 20 °C, voda 54.4 °C, TUV
zásobník 46.6 °C, bez chyb. Defrost reg: 0 i 0xFF znamená OFF (manuál),
bool() by 255 četl jako zapnuto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:40:15 +02:00
Dusan Vojacek
4ff5f7c3eb HOTFIX: poll_inverter SELECT bez deye_zero_export_mode — KeyError zastavil inverter telemetrii všech lokalit
All checks were successful
CI and deploy / migration-check (push) Successful in 34s
CI and deploy / deploy (push) Successful in 1m10s
V100 rozšířil view i logiku, ale SELECT v collectoru zůstal bez nového
sloupce — row['deye_zero_export_mode'] padal každou minutu od deploye
287353b (~33 min výpadek inverter telemetrie; EV/pool jely dál v heartbeat
rytmu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:01:31 +02:00
Dusan Vojacek
406b6a7f8f HARD LIMIT exportu jako tvrdé pravidlo §4.19 + test
All checks were successful
CI and deploy / migration-check (push) Successful in 18s
CI and deploy / deploy (push) Successful in 1m2s
Překročení rezervovaného exportu na fakturačním elektroměru (home-01
13.5 kW) = pokuta v řádu desítek tisíc Kč/kW. Invariant: reg 143
(svorky) <= max_export_power_w (ulice) VŽDY; feed-forward navyšování
o měřenou spotřebu mezi střídačem a CT ZAKÁZÁNO (výpadek spotřeby =
přestřelení ulice). Návrh feed-forwardu z 2026-06-12 večer zavržen
před implementací na pokyn uživatele.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:40:11 +02:00
Dusan Vojacek
287353b082 docs: home-01 CT doinstalováno majitelem (reg 619 vs 625), changelog — dopatch k d8f6de7
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:27:15 +02:00
Dusan Vojacek
d8f6de77d5 home-01: ulice z externího CT (reg 619) + celková spotřeba domu; Deye zero-export to CT
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 1m5s
Fakturační elektroměr ~8 kW vs Deye 13.5 kW: hlavní okruhy domu (vč. wallboxu,
EV 10.5 kW při load 164 W) visí MEZI střídačem a CT u elektroměru — reg 625
(svorky) ani 653 (UPS port) je nevidí. home-01 bylo chybně vedeno jako bez CT.

V100: deye_zero_export_mode=2 (reg 142 → zero export to CT, propíše exporter),
sloupce inverter_grid_port_w + ups_load_w, komentáře se změnou sémantiky.
Collector: grid_power_w z reg 619 (instalace s CT; fallback 625),
load_power_w = pv + baterie + grid = celkový dům. R__049 +2 parametry,
R__052 + deye_zero_export_mode. Audit/baseline od teď počítají se skutečnou
ulicí; historie (do 2026-06-12) nese svorky střídače — přepočet ekonomiky po
faktuře. Baseline rebuild doporučen po týdnu nových dat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:24:56 +02:00
Dusan Vojacek
5530253662 EV arrival hold: po detekci píchnutí okamžitě 0 A — nabíjení spouští až plán
All checks were successful
CI and deploy / migration-check (push) Successful in 28s
CI and deploy / deploy (push) Successful in 1m15s
TeltoCharge po připojení kabelu sám rozjede nabíjení svým defaultem; EMS ho
dosud dohnal až exportem setpointů (do 15 min). _on_ev_arrival nyní před
replanem zapíše přes journal telto_amps_to_use=0 (write_ev_arrival_hold),
replan+export vzápětí nastaví plánované ampéry. Watchdog (300 s → failsafe
8 A) zachován — výpadek EMS auto nenechá na 0 A.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:52:10 +02:00
Dusan Vojacek
80623573ea Merge branch 'worktree-agent-a53f3277d55fecfcb' into dev
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 1m14s
2026-06-12 19:40:50 +02:00
Dusan Vojacek
73a665457d feat(harness): extract_fixtures --keep-ev — zmrazit EV sessions do fixture
Default zůstává vynulování otevřených EV sessions (historické okno bez
session); --keep-ev je zmrazí pro EV scénáře (deadline, měkký cíl, min.
výkon wallboxu). meta.keep_ev ve fixture dokumentuje způsob pořízení.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:32:06 +02:00
Dusan Vojacek
3b5f07b66e feat(planner): EV účtování v2 — headroom fix, deadline boundary, min. výkon WB, via-bat reporting
Hloubková diagnóza EV potvrdila: oportunitní ekonomika via-baterie je v LP
správně, ale okraje lhaly nebo byly nevykonatelné:

- V099 + R__039: ems.ev_session.opportunistic_value_czk_kwh (NULL = zdědit
  z asset_vehicle, 0 = vypnout pro session); headroom_wh z max(target_soc,
  soc_at_connect) — „nenabíjet" (nízký target) už paradoxně NEzvětšuje
  oportunistickou vrstvu; vehicles JSON nese min_power_w wallboxu.
- R__015: patch klíč opportunistic_value_czk_kwh (validace >= 0).
- solver_v2: (a) deadline suma range(t_dl) — slot začínající v deadline už
  nepatří „do deadline"; (b) Σ ev_direct <= gi + PV (fyzikální split);
  (c) binárka ev_on → setpoint ∈ {0} ∪ [min_power_w, max] (konec 400–900 W
  nevykonatelných setpointů); (d) bez session EV == 0 (stop-session i golden
  fixtures — žádné pumpování při buy<0); dekompozice total == needed − unmet
  + opp i pro needed = 0; (e) battery_arbitrage_czk = via_bat kWh × oportunitní
  cena (min sell exportního slotu téhož pražského dne, jinak terminal value)
  místo konstantní 0. Oportunismus PO deadline zůstává POVOLENÝ (rozhodnutí:
  auto často doma, odjezd řeší rolling replan).
- R__033: fn_plan_current_bundle.intervals + ev1/ev2_via_bat_w (UI nemá cenit
  EV kWh z baterie slotovým buy).

Golden gate beze změny snapshotů (v1 nedotčen, fixtures bez EV sessions);
solver_v2_eval před/po identický (CELKEM −1283.5 Kč, Δ −221.9 vs v1);
tests/test_solver_v2.py +7 testů; plná sada 310 passed / 4 xfailed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:31:56 +02:00
Dusan Vojacek
283443d6bd Merge branch 'worktree-agent-a288972b643cdefcc' into dev
All checks were successful
CI and deploy / migration-check (push) Successful in 22s
CI and deploy / deploy (push) Has been skipped
2026-06-12 19:17:01 +02:00
Dusan Vojacek
48f5a6b00b Discord EV: dva výběry (odjezd × cíl) místo řady tlačítek
Arrival zpráva má dva persistent Selecty (custom_id ev:<site>:<charger>:dep
a :tgt, obsluha on_interaction + regex → přežijí restart):
„Kdy odjíždíš?" za 2 h | za 4 h | dnes večer 18:00 | zítra ráno 7:00 |
zítra poledne 12:00 | pondělí ráno 7:00; „Kolik potřebuješ?" 30/50/70/100 %
| Nenabíjet. Každý výběr okamžitě PATCHne session přes
fn_ev_session_apply_patch jen ve své dimenzi (absolutní deadline
Europe/Prague, nejbližší budoucí výskyt; pevná volba smí přes 48 h),
druhý rozměr zůstává z fn_ev_session_defaults. Pak replan + export a edit
zprávy přepočteným plánem (build_ev_plan_summary) + potvrzením. Whitelist
DISCORD_ALLOWED_USER_IDS i bot-first/webhook fallback beze změny; legacy
tlačítka h2/h4/morning/full/stop starších zpráv dál obsloužená.

Testy: mapování výběr→patch, absolutní deadline z voleb (půlnoc, pondělí
z pátku >48 h, pondělí ráno v pondělí), parse, legacy akce — bez DB/sítě.
Docs: discord-ev-interaction.md (nové UI, no-click = pohotovostní režim
30 % + oportunismus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:14:56 +02:00
Dusan Vojacek
60eda46dd7 V098: týdenní požadavky EV (ev_weekly_requirement) + fn_ev_session_defaults
Tabulka ems.ev_weekly_requirement (dow 0=pondělí..6, target_soc_pct,
deadline_hour Europe/Prague, enabled; unique per vozidlo+den) se seedem
tesla-my pondělí 07:00 → 90 %. Nová ems.fn_ev_session_defaults(vehicle,
arrival) → jsonb {target_soc_pct, deadline, source}: kaskáda týdenní
požadavek (výskyt do 48 h) → forecast z ev_usage_stats
(target_soc_forecast_enabled, chování V089 beze změny) → defaulty vozidla
(deadline = příští výskyt default_deadline_hour). fn_ev_session_transition
ji volá při založení session (SQL-first, Python beze změny); comment
funkce sjednocen na styl bez parametrů.

Docs: ev-charging.md sekce Týdenní požadavky + kaskáda, CLAUDE.md seznam fn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:14:40 +02:00
Dusan Vojacek
f0e81def5d open question: TUV delta jednotky °C/min vs užití v solveru (~60× podhodnocené chladnutí)
All checks were successful
CI and deploy / migration-check (push) Successful in 15s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:09:33 +02:00
Dusan Vojacek
815a233049 feat(telemetry): idle-skip zápisů — neukládat 1min řádky idle zařízení
Some checks failed
CI and deploy / migration-check (push) Successful in 28s
CI and deploy / deploy (push) Failing after 17m56s
Slabý server: dict (tabulka, asset_id) → (signature, last_stored_at);
_idle_skip ukládá vždy při změně signature, aktivitě, po startu procesu
a heartbeat po > 840 s (každý 15min bucket má ≥ 1 řádek).

- telemetry_ev_charger: aktivní = status != 'available' nebo power > 50 W;
  signature (status, výkon na 100 W)
- telemetry_pool_pump: aktivní = is_on nebo power > 5 W (ON řádky 1/min
  kvůli on_minutes); signature (is_on, výkon na 10 W)
- telemetry_loxone_sensor: jen změna hodnoty ≥ 0.1 / heartbeat
- telemetry_heat_pump: aktivní = mode != 'off' nebo defrost; signature
  (mode, teploty na 0.2 °C)
- telemetry_inverter: beze změny — NIKDY se nepřeskakuje (audit Wh split,
  baseline, SoC plánovače)

Detekce příjezdu/odjezdu EV: previous_status přesunut z posledního řádku DB
do in-memory _EV_LAST_STATUS (po startu seed z vw_latest_ev_charger —
přechod během výpadku se pozná, prázdná DB nevystřelí falešný příjezd);
fn_ev_session_transition se volá jen při změně statusu.

PoolCard: staleness práh 5 → 16 min (> heartbeat 840 s).
Docs: telemetry.md sekce „Idle-skip zápisů" (pravidla pro nové čtecí dotazy:
sumy/gapfill, ne avg přes řádky), planning-changelog (TUV °C/min).
Testy: tests/test_telemetry_idle_skip.py — _idle_skip jednotkově + EV
arrival/departure přežije skip i restart procesu (303 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:06:41 +02:00
Dusan Vojacek
f71bc944b4 fix(db): čtecí cesty telemetrie robustní vůči řídkým řádkům (idle-skip)
- fn_fill_audit_interval: EV a TČ agregace sum(power_w)/15 místo avg přes
  přítomné řádky — avg by při řídké telemetrii nadhodnotil aktivitu části
  slotu; chybějící minuta = 0 W (idle). TČ drží NULL bez power_w (MIM-B19N).
- fn_update_tuv_usage_stats: delta TUV normalizovaná na °C/min délkou mezery
  mezi řádky (gap_min), mezery > 30 min vyloučeny; pro hustá 1min data
  numericky identické s původním LAG.
- vw_pool_pump_day_energy: komentář — on_minutes drží invariant „zapnuté
  čerpadlo se ukládá každou minutu".

Pro hustá 1min data beze změny výsledků; připravuje idle-skip zápisů
v telemetry_collector (navazující commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 19:06:23 +02:00
Dusan Vojacek
e41840cb7d V097: wallboxy TeltoCharge na reálný RS485 převodník 172.16.1.16 (unit 1/2, 9600 8N1)
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 1m4s
Nahrazuje placeholder IP 192.168.1.101/.102 ze seedu; sdílená sběrnice
s plánovaným Chint elektroměrem (unit 3). Teltonika strana čeká na povolení
Modbusu v aplikaci.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:31:49 +02:00
Dusan Vojacek
8289e32a03 docs: heat-pump.md odkaz na MIM-B19N registry (dopatch k d63a85a)
Some checks failed
CI and deploy / deploy (push) Has been cancelled
CI and deploy / migration-check (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:24:36 +02:00
Dusan Vojacek
d63a85a2ea TČ Samsung přes MIM-B19N: endpoint 172.16.1.17, plný poll, registry doc
Some checks failed
CI and deploy / migration-check (push) Failing after 7m29s
CI and deploy / deploy (push) Has been skipped
- V096: endpoint home-01 TČ z placeholderu 192.168.1.103 na reálný Waveshare
  RS485 TO POE ETH (B) 172.16.1.17:502; telemetry_heat_pump.room_temp_c.
- R__048: fn_telemetry_heat_pump_sample rozšířena (water_inlet, room_temp,
  defrost, alarm_code) — drop/comment bez parametrů dle konvence.
- poll_heat_pump: místo TODO stubu (zapisoval dummy 45/55 °C!) skutečné čtení
  MIM bloku 50-75 + defrost reg 2; gate na comm_status ready (jinak skip);
  operating_mode off/heat/cool/auto/dhw/error; power_w NULL (MIM příkon nemá).
- docs/04-modules/modbus-registers-mim-b19n.md (mapa, 9600 8E1, DIP adresa,
  troubleshooting E6xx) + heat-pump.md odkaz.

Živý stav: TCP :502 OK, Modbus bez odpovědi (čeká na protokol převodníku /
paritu EVEN / polaritu A-B — checklist v docu).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 18:24:10 +02:00
Dusan Vojacek
1406796a62 Perf ROOT CAUSE: rolling faktor se počítal per řádek — hoist do proměnné (canonical PV fn 4.5s→~0.45s)
All checks were successful
CI and deploy / deploy (push) Successful in 46s
CI and deploy / migration-check (push) Successful in 23s
Skutečná příčina 600k buffers: CTE factor/factor_raw (single-ref) PG inlinuje
do projekce with_factor → fn_pv_forecast_correction_factor (48 ms / 1.9k
buffers) se vyhodnocovala ~300× per výstupní slot. Plan cache s tím neměl nic
společného (dřívější count(*) měření projekci zahodilo, proto vycházelo
0.42 s). Faktor se teď počítá jednou do v_rolling_factor a vkládá jako
literál (13. argument format).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:48:08 +02:00
Dusan Vojacek
8554cd1bc1 Perf: canonical PV fn přes plpgsql execute format — vynucený plán dle skutečných hodnot
All checks were successful
CI and deploy / migration-check (push) Successful in 21s
CI and deploy / deploy (push) Successful in 51s
set plan_cache_mode=force_custom_plan na SQL funkci v PG 18 nezabral (stále
generický plán, 4.5-9 s / 600k buffers). plpgsql EXECUTE s literály = vždy
čerstvý plán: tělo s konstantami změřeno 0.42 s / 34k buffers. Signatura,
chování i výstup beze změny.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:54:44 +02:00
Dusan Vojacek
62a5c64f77 HOTFIX web: /status/full 500 (str→tzinfo), /plan/compare 500 (chybějící comparison), canonical PV fn 4.5s→0.4s (force_custom_plan)
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 1m17s
1) full_status._iso_utc dostával z JSONB bundle stringy → AttributeError → 500
   celého /status/full; nyní parsuje přes _parse_ts.
2) /plan/compare: NameError — 'comparison = _bundle_from_current(compare_raw)'
   se nikdy nesestavilo (smazaný řádek), endpoint vždy 500.
3) fn_forecast_pv_slots_range_canonical_ab: PG 18 cachuje plány SQL funkcí →
   generický plán 4.5 s / 607k buffers; set plan_cache_mode=force_custom_plan
   → 0.4 s / 34k (změřeno explain analyze na živé DB). Táhne /plan/current,
   /plan/compare i rolling plánovač.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:48:10 +02:00
Dusan Vojacek
dd3bd55c0e Fix reg 340: záporný buy+sell má přednost před úsvitovou výjimkou
All checks were successful
CI and deploy / migration-check (push) Successful in 13s
CI and deploy / deploy (push) Successful in 53s
Větev 'slabý úsvit (forecast<1500 W) → reg 340 neposílat' zastínila větev
'buy<0 a sell<0 + pole B → pv_a_allowed=0' — při hluboce záporných cenách
za úsvitu by se pole A nezavřelo. Prohozeno pořadí; opravuje pre-existing
fail test_neg_buy_and_sell_with_pv_b_forces_pv_a_off (293 passed, 4 xfail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:19:10 +02:00
Dusan Vojacek
b66bb712e4 HOTFIX dashboard: pv-slots-corrected čte delta profil z cache (R__079 → fn_pv_forecast_delta_profile_cached)
All checks were successful
CI and deploy / migration-check (push) Successful in 14s
CI and deploy / deploy (push) Has been skipped
Endpoint GET /sites/{id}/forecast/pv-slots-corrected (dashboard) volal těžkou
fn_pv_forecast_delta_profile inline (~44 s/site na prod) — uživatel: 45 s
response, telemetry endpoint vyhladověl. Kanonická plánovací řada (R__088) už
cache používala, R__079 ne. Cache je pro všechny 4 lokality naplněná, čtenář
po HOTFIX 2/2 nikdy nepočítá.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:14:07 +02:00
Dusan Vojacek
c7f595c587 HOTFIX BA81: export plan guard neodstavuje pole B při kladné vykupní ceně
Some checks failed
CI and deploy / deploy (push) Has been cancelled
CI and deploy / migration-check (push) Has been cancelled
_apply_export_plan_guard při export_mode=NONE (plán nabíjí baterii, neexportuje)
vynucoval _passive_no_export_guard s export_ban=True + deye_gen_cutoff_enabled=True
bez ohledu na cenu -> reg 178 bity 0-1=3 (MI cutoff) + reg 145=0 a mikroinvertory
(pole B) fyzicky stály i při sell +1.36 Kč (BA81 dnes: gen port ~0 W od 12:16Z,
SoC 64 %, stringy 4.2 kW do baterie). Tvrdý ban nově JEN při záporné vykupní;
při kladné guard dál drží PASSIVE/143=0/baterie nevybíjí do sítě, ale MI jedou
(absorbce do baterie, přetok se prodá). Plánový z_gen_cutoff se respektuje.

Pre-existing fail test_neg_buy_and_sell_with_pv_b_forces_pv_a_off padá i na main
(pv_a_allowed_w None != 0) — nesouvisí, řešit zvlášť.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 15:07:17 +02:00
Dusan Vojacek
a208cc627d Konvence: comment on function / drop function VŽDY bez parametrů; názvy fn unikátní (žádné overloady)
All checks were successful
CI and deploy / migration-check (push) Successful in 41s
CI and deploy / deploy (push) Successful in 1m4s
Od uživatele po 42883 incidentu (R__018 comment na staré signatuře shodil
2 deploye): odkaz přes signaturu se rozbije při každé změně parametrů.
R__018 převeden na bez-parametrovou formu, pravidlo v CLAUDE.md Konvencích.
Zbylých 51 parametrizovaných comment on / 6 dropů v repu funguje (míří na
aktuální signatury) — normalizovat při dotyku.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:57:05 +02:00
Dusan Vojacek
9213d3544b HOTFIX 3: comment signatura (int, boolean) — deploy R__018 padal 42883; force refresh po PATCH kalibrace
All checks were successful
CI and deploy / migration-check (push) Successful in 18s
CI and deploy / deploy (push) Successful in 1m0s
Throttle commit změnil signaturu fn_refresh_site_pv_delta_profile_cache na
(int, boolean default false), ale comment on function dál mířil na (int) →
repeatable migrace selhala (function does not exist), oba deploye (7da7205
i 18bf93a) spadly — na produkci NENÍ nic z delta hotfixů. PATCH kalibrace
nově volá refresh s p_force=true (throttle nesmí zadržet přepočet po změně
parametrů).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:54:42 +02:00
Dusan Vojacek
18bf93a801 HOTFIX 2/2: delta profil — čtenář NIKDY nepočítá, jen čte cache
Some checks failed
CI and deploy / migration-check (push) Successful in 18s
CI and deploy / deploy (push) Failing after 28s
Postřeh uživatele odhalil druhou půlku problému: _cached getter měl
max_age 30 min s INLINE fallbackem na plný 44s přepočet — dosud to maskoval
15min refresh; po throttlu refresh jednou za 6 h by KAŽDÉ čtení po
vystárnutí cache (plan/current, canonical sloty plánovače) spouštělo 44 s.
Čtenář teď vrací cache bez ohledu na stáří; počítá výhradně refresh
(throttle 6 h + denní catch-up). Inline jen first-run/analytická okna.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:46:51 +02:00
Dusan Vojacek
7da7205c07 Hotfix merge: DB výkon (delta cache throttle) + presence watcher + Tesla zákopy
All checks were successful
CI and deploy / migration-check (push) Successful in 26s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:25:32 +02:00
Dusan Vojacek
11767dfdbd HOTFIX výkon: delta-profile cache 44 s/site se přepočítávala každých 15 min
Some checks failed
CI and deploy / deploy (push) Has been cancelled
CI and deploy / migration-check (push) Has been cancelled
fn_fill_forecast_accuracy (tick :02/:17/:32/:47, 4 lokality) na konci volá
fn_refresh_site_pv_delta_profile_cache → fn_pv_forecast_delta_profile =
agregace 120 dní nad 4.1M řádky forecast_accuracy = ~44 s/site na prod
→ ~3 minuty plné DB zátěže KAŽDOU čtvrthodinu → timeouty /sites a
/health/detailed (30 s), celodenní 'pomalý server'.

Fix: (1) cache refresh throttle 6 h přes delta_profile_cached_at (+p_force;
profil má 14d poločas — 4×/den bohatě stačí); (2) 15min tick lookback
48→3 h (insert část); (3) denní 48h catch-up job 05:50.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:25:27 +02:00
Dusan Vojacek
e490e8cd26 Presence watcher: cost-aware pacing (Fleet API je placené)
All checks were successful
CI and deploy / migration-check (push) Successful in 48s
CI and deploy / deploy (push) Has been skipped
- list poll 5→10 min; vehicle_data JEN při přechodu asleep→online nebo
  max 1×/15 min (data /usr/bin/zsh.002/req); wake nikdy (gate na online)
- otevřená ev_session = auto u wallboxu → ŽÁDNÉ API cally (při AC nabíjení
  auto nespí — bez gatu by data tekla celou noc nabíjení)
Odhad: </měsíc (online okna mimo wallbox jsou krátká).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:19:41 +02:00
Dusan Vojacek
2122fa2035 Tesla presence watcher: geofence, ev_presence_obs, 'píchni auto' pobídka
All checks were successful
CI and deploy / migration-check (push) Successful in 47s
CI and deploy / deploy (push) Has been skipped
- V095 ems.ev_presence_obs (state/at_home/distance/charging/shift per ~5 min)
- tesla_client: get_vehicle_api_state (jen /vehicles — nebudí), haversine_m
- collector poll_tesla_presence: online → poloha → geofence 150 m vs GPS site;
  přechod pryč→doma + Disconnected → Discord pobídka s aktuálním přebytkem
  (cooldown 2 h); vše logováno pro budoucí dostupnostní statistiku
- 6 testů (haversine, přechody); docs: zákopy reauth procesu (6 bodů)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:14:48 +02:00
Dusan Vojacek
ea4ca0e3de reauth.sh: prázdný seznam vozidel = chybí partner registrace (ne spánek)
All checks were successful
CI and deploy / migration-check (push) Successful in 57s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:12:45 +02:00
Dusan Vojacek
ca4340ffdd CI retrigger po flyway repair (failed V093 odstraněna z historie)
All checks were successful
CI and deploy / migration-check (push) Successful in 42s
CI and deploy / deploy (push) Successful in 1m26s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:49:52 +02:00
Dusan Vojacek
5ae6b609cc FIX V093: asset_vehicle nemá sloupec notes — migrace failovala a blokovala
All checks were successful
CI and deploy / migration-check (push) Successful in 17s
CI and deploy / deploy (push) Has been skipped
všechny deploye od ~13:40 (R__082 OTE fix, V094, bot fallback ve frontě).
V093 nebyla aplikována (transakční rollback) — úprava failed migrace je
legitimní (immutability platí pro APLIKOVANÉ); na serveru nutný jednorázový
'flyway repair' (smaže failed záznam z historie).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:48:12 +02:00
Dusan Vojacek
3d51176819 Hotfix merge: Discord notifikace (bot fallback)
All checks were successful
CI and deploy / migration-check (push) Successful in 17s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:44:32 +02:00
Dusan Vojacek
b651191fdb FIX: Discord notifikace se od začátku tiše zahazovaly — bot REST fallback
Some checks failed
CI and deploy / deploy (push) Has been cancelled
CI and deploy / migration-check (push) Has started running
Všechny site webhook URL jsou NULL a env DISCORD_WEBHOOK_URL nebyl nastaven
→ send_discord vždy skončil na 'not configured, skipping' (uživatel: 'nikdy
mi nic nepřišlo'). Fix: fallback přes bota (REST POST do DISCORD_EV_CHANNEL_ID
— token už v .env, žádný webhook netřeba); webhook má přednost, kdyby ho
uživatel později nastavil per site. Bot navíc při startu pošle ' online'
(viditelný důkaz, 1× per deploy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:44:30 +02:00
Dusan Vojacek
ee3581da02 Hotfix merge: OTE import (format %.3f) + Tesla reauth/redirect opravy + bazén příprava + měkký EV cíl
All checks were successful
CI and deploy / migration-check (push) Successful in 18s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:39:29 +02:00
Dusan Vojacek
de849e7e8b HOTFIX: fn_ote_day_signals_prague — Postgres format() neumí %.3f
Some checks failed
CI and deploy / migration-check (push) Has been cancelled
CI and deploy / deploy (push) Has been cancelled
OTE import dnes spadl (InvalidParameterValueError: unrecognized format()
type specifier '.') — denní cenové signály poprvé trefily větev s %.3f/%.2f;
PG format() zná jen %s/%I/%L. Náhrada %s + round(x, N) ve všech 7 výskytech.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:39:27 +02:00
Dusan Vojacek
ce1ca8eecb Tesla: graceful 400 na token refresh + one-shot reauth skript
Some checks failed
CI and deploy / migration-check (push) Failing after 16m36s
CI and deploy / deploy (push) Has been skipped
400 invalid_grant = spálený token rotací NEBO ~10min výpadek po revokaci
souhlasu (Tesla) — místo tracebacku log s návodem a return None (EMS jede
dál na defaultech). deploy/tesla/reauth.sh: authorize URL → výměna → DB →
ověření v jednom kroku (žádná příležitost pro rotační past).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:14:04 +02:00
Dusan Vojacek
5a10da57e9 Tesla OAuth: redirect URI je /t-auth (oprava všude + Caddy rewrite)
All checks were successful
CI and deploy / migration-check (push) Successful in 26s
CI and deploy / deploy (push) Has been skipped
V dev portálu je registrováno https://ems.vojacek.eu/t-auth — docs i setup
skript diktovaly /tesla/callback (mismatch = invalid_auth_code / chybné
návody). Caddy blok nově servíruje callback stránku na /t-auth (rewrite).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:45:57 +02:00
Dusan Vojacek
315bd0ca46 v2 test: levný sell (<opp hodnota) posílá přebytek do auta, ne do sítě
All checks were successful
CI and deploy / migration-check (push) Successful in 5m31s
CI and deploy / deploy (push) Has been skipped
Postřeh uživatele — pásma nízkého výkupu před zápornými okny: mechanismus
měkkého cíle to už řeší (opp 1 Kč/kWh > sell 0.3 → auto vyhraje), test
to dokládá: plná domácí baterka + 9 kW PV → ~17 kWh do auta, minimální export.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:22:56 +02:00
Dusan Vojacek
85dff7f13e v2: měkký EV cíl — oportunistické nabíjení nad target (+ strop energie)
All checks were successful
CI and deploy / migration-check (push) Successful in 44s
CI and deploy / deploy (push) Has been skipped
Uživatel: 'potřebuju do X % (tvrdý), ale klidně dobij na 100 % když je to
skoro zadarmo; při záporných cenách radši do auta než nechat na střeše'.

- V094 asset_vehicle.opportunistic_value_czk_kwh (default 1.0; = hodnota
  ušetřeného BUDOUCÍHO nabíjení — auto neumí zpět, žádný noční prodej)
- R__039 ev_sessions: + headroom_wh ((100−target) % kapacity) + opp value;
  session se nenuluje po dosažení targetu, dokud má headroom
- solver_v2: dekompozice Σ(EV) == needed − unmet + opp, opp ∈ [0, headroom],
  odměna opp×value; zároveň FIX latentního bugu — při buy<0 chyběl strop
  celkové energie do auta (model mohl pumpovat bez limitu)
- 3 testy (neg ceny sají nad target po strop; běžné ceny ne; cap při opp=0);
  eval fixtures beze změny (sessions null)

Víkend (pátek nízký tvrdý cíl + víkendová negativa → samo doplní do 100 %)
vyplývá z mechanismu, žádná speciální logika.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:17:59 +02:00
Dusan Vojacek
2325bbcbd6 Tesla LFP: kapacita 62.5 kWh (bylo 75 = LR) + default cíl 100 %
All checks were successful
CI and deploy / migration-check (push) Successful in 29s
CI and deploy / deploy (push) Has been skipped
Model Y 2025 Standard RWD s LFP: menší pack, ale pravidelné 100 % je žádoucí
(balancování). Kapacita vstupuje do energy_needed a EV usage statistik.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:10:45 +02:00
Dusan Vojacek
15d47e8a80 Bazén: sezóna (schedulable), filtrace dle teploty vody, Loxone čidla
All checks were successful
CI and deploy / migration-check (push) Successful in 42s
CI and deploy / deploy (push) Has been skipped
- V092: ems.loxone_sensor + telemetry_loxone_sensor (hypertable) — generické
  čtení Loxone hodnot (poslouží i ohřevu/akumulačce); pool sloupce teplotní
  funkce (ref/base/per_c/min/max) + water_temp_sensor_id
- R__098 fn_pool_daily_runtime_min: clamp(base+per_c×(t−ref)) z poslední
  teploty <24 h, fallback daily_runtime_min; JSON detail pro UI/solver
- collector poll_loxone_sensors: /jdev/sps/io/<name>/state, LL.value parse,
  no-op bez čidel
- sezóna = schedulable přepínač (dokumentováno vč. SQL); hranice filtrace ×
  ohřev TČ (oddělené logiky, sdílí jen čidlo)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:47:03 +02:00
Dusan Vojacek
f3eb16892f Milník: Discord bot fáze B (EV tlačítka)
All checks were successful
CI and deploy / migration-check (push) Successful in 21s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:41:35 +02:00
Dusan Vojacek
0e7f7b69ae Discord bot fáze B: tlačítka na EV zprávě → patch session + okamžitý replan
All checks were successful
CI and deploy / migration-check (push) Successful in 21s
CI and deploy / deploy (push) Has been skipped
services/discord_bot.py: gateway klient jako lifespan task (spojení ven,
žádný veřejný endpoint; bez DISCORD_BOT_TOKEN tiše spí). Tlačítka
[za 2h][za 4h][ráno][do plna][nenabíjet] s custom_id ev:<site>:<charger>:<akce>
(přežijí restart); whitelist DISCORD_ALLOWED_USER_IDS; akce = fn_ev_session_
apply_patch → run_rolling_replan → export_setpoints → edit zprávy novým plánem.

services/ev_notify.py: sdílený builder souhrnu (vyčleněno z collectoru),
send bot-first s webhook fallbackem. requirements: discord.py>=2.4.
7 testů helperů (parse, deadline akce vč. morning přes Prague TZ).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:41:05 +02:00
Dusan Vojacek
08a43aa236 Milník: bazén vizualizace + EV Discord notifikace (fáze A)
All checks were successful
CI and deploy / migration-check (push) Successful in 50s
CI and deploy / deploy (push) Has been skipped
PoolCard na Dashboardu (vw_latest_pool_pump, vw_pool_pump_day_energy, granty),
Discord souhrn po příjezdu EV, docs (discord-ev-interaction, pool-shelly,
ev-charging), gitignore worktrees.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:21:00 +02:00
Dusan Vojacek
a7403227c1 gitignore: .claude/worktrees (omylem přibalený embedded repo z git add -A)
All checks were successful
CI and deploy / migration-check (push) Successful in 20s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:59:28 +02:00
Dusan Vojacek
29d854f23d Bazén vizualizace + EV Discord notifikace po příjezdu (fáze A)
Some checks failed
CI and deploy / deploy (push) Has been cancelled
CI and deploy / migration-check (push) Has been cancelled
- R__097: vw_latest_pool_pump + vw_pool_pump_day_energy (denní kWh z delty
  čítače, minuty běhu) + ems_anon granty
- PoolCard na Dashboardu: stav/W/dnešní kWh+hodiny/7denní mini sloupce
- _notify_ev_arrival_plan: po příjezdu EV Discord souhrn (SoC auta → cíl,
  deadline, nabíjecí okna shlukovaná ze slotů aktivního plánu, ø cena)
- docs/discord-ev-interaction.md: fáze B (bot s tlačítky přes gateway —
  žádný veřejný endpoint; čeká na DISCORD_BOT_TOKEN od uživatele)
- docs: pool-shelly + ev-charging aktualizovány (pravidlo docs 1:1)

První commit na dev větvi (nová kadence: deploy až s milníkovým merge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:59:09 +02:00
Dusan Vojacek
5d2c09401a Vývojová kadence: dev větev (CI bez deploye), merge do main 1×/den/milník
All checks were successful
CI and deploy / migration-check (push) Successful in 21s
CI and deploy / deploy (push) Has been skipped
Tři rychlé pushe dnes = 3 deploye ve frontě a vynechané rolling ticky.
Workflow: dev přidán do validačních větví (deploy zůstává jen main).
Pravidlo + deploy okno v CLAUDE.md Konvencích.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:55:15 +02:00
Dusan Vojacek
6671157e8e Pravidla: dokumentace 1:1 s implementací je povinná součást každé změny
All checks were successful
CI and deploy / deploy (push) Successful in 51s
CI and deploy / migration-check (push) Successful in 21s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:38:14 +02:00
Dusan Vojacek
ab17e86900 Dokumentace: noc 11.→12. 6. — v2 aktivní, robustnostní trojice, EV forecast, CI opravy
Some checks failed
CI and deploy / migration-check (push) Successful in 16s
CI and deploy / deploy (push) Has been cancelled
- planning-changelog.md: záznam 2026-06-12 (přepnutí na v2, noční polštář /
  PV front-load / denní rampa s tabulkou, EV usage forecast, zimní posouzení)
- planning.md: default PLANNING_ENGINE_VERSION=v2 + sekce robustnosti
- refactor-clean-planner.md: Fáze 3 = v2 AKTIVNÍ
- ev-charging.md: EV spotřební forecast (sběr/statistiky/aktivace)
- consumption.md: bazál odečítá bazén
- deployment-self-hosted.md: tři CI vady + self-install deploy.sh + stop před flyway

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:37:20 +02:00
Dusan Vojacek
e0410f9638 v2: denní SoC bezpečnostní rampa — ráno dotáhnout rezervu, pak prodávat
All checks were successful
CI and deploy / migration-check (push) Successful in 30s
CI and deploy / deploy (push) Successful in 1m32s
KV1 pozorování uživatele: ráno baterie na 11 % (min 10), prodává se do sítě
— nenadálý odběr/mrak by se kupoval za fixních 6.35. v1 mělo denní rampu
(safety_soc_target_wh z R__063: reserve 30 % ráno → reserve+noc večer,
6-19 h, flag planner_daytime_charge_target_enabled) — v2 ji ignoroval.

Mechanismus (vzor nočního polštáře): deficit pod rampou platí za KAŽDÝ slot
nájem buy×faktor (V091 asset_battery.planner_safety_soc_risk_factor,
default 0.05; 0=vypnuto) → ráno se nejdřív doplní rezerva (4 h deficitu
1 kWh při buy 6.35 ≈ 5.1 Kč > sell ~2.5), extrémní sell špička smí deficit
racionálně podstoupit. R__039 + db_io + 2 testy (KV1 scénář, spike).

Eval fixtures beze změny (sloupec v context_json fixtures není → 0);
živá produkce dostane faktor přes fn_planning_site_context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:17:19 +02:00
Dusan Vojacek
2932d48080 v2: PV-risk front-load — nabít v neg okně co nejdřív (nejistota predikce)
All checks were successful
CI and deploy / migration-check (push) Successful in 29s
CI and deploy / deploy (push) Successful in 1m0s
v1 to řešil rampou (plný výkon než se řeže pole A — zelený bonus B, riziko
večerního mraku). v2 byl k načasování v okně sell<0 indiferentní (PV zdarma
kdykoliv) a směl nabíjení odložit — odklad ale spoléhá na predikci.

Mechanismus: malá prémie za držení energie dřív (objective −= soc[t] ×
frontload v neg slotech). Rozbíjí indiferenci směrem k front-loadu, nikdy
nepřebije skutečné ceny. Velikost z DB: asset_battery.
planner_pv_risk_frontload_czk_kwh (V090, default 0.01; 0 = vypnuto),
přes fn_planning_site_context (R__039). Test: 4 sloty plným tempem od startu.
Eval fixtures beze změny (sloupec v nich není → 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:55:22 +02:00
Dusan Vojacek
e464b114b9 v2: noční SoC polštář — placená rezerva na neočekávaný noční nákup
All checks were successful
CI and deploy / migration-check (push) Successful in 34s
CI and deploy / deploy (push) Successful in 1m0s
Postřeh uživatele: v1 držel přes noc rezervu nad min_soc (chyba predikce
noční spotřeby = neplánovaný drahý nákup); v2 slot fieldy night_baseload_*
ignoroval a směl plánovat vybití až na min_soc.

Mechanismus ve filozofii v2 (riziko jako cena, ne okno/penalta):
soft floor soc[t] >= min_soc + night_baseload_buffer_wh[t] (z DB
planner_night_baseload_buffer_percent, počítá R__063, klesá k 0 do rána);
porušení placené buy cenou slotu → extrémní sell špička smí polštář
racionálně prodat, běžná noc ne (buy > sell).

Eval na fixtures: v2 stále lepší na všech (+221.9 Kč vs v1; −10 Kč proti
stavu bez polštáře = cena robustnosti). BONUS: těsnější LP zrychlil extrémní
fixtures z 10 s timeoutu na 0.3–2.6 s. +3 testy (drží/spike prodá/feasible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:49:21 +02:00
Dusan Vojacek
4095f0f912 EV spotřební forecast: týdenní rytmus vozidla → target SoC a deadline session
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 56s
Myšlenka uživatele: pondělní služebka ~150 km (~35 kWh) chce skoro plnou,
konec týdne stačí míň, víkend = levné sloty na přípravu pondělka.

- V089: ev_vehicle_obs (odometer+SoC při příjezdu/ODJEZDU — auto v obou
  okamžicích vzhůru, žádné buzení navíc), ev_trip (km z odometru, kWh z ΔSoC;
  nabíjení cestou → charged_away flag), ev_usage_stats per (vozidlo, DOW);
  asset_vehicle: target_soc_forecast_enabled (default false), min_target_soc_pct
- R__096: fn_ev_build_trips (párování), fn_update_ev_usage_stats (job 00:50),
  fn_ev_next_departure (příští typický odjezd, >=4 vzorky, >=3 km),
  fn_ev_required_soc (P80 spotřeby dne + 10 p.b., clamp [min_target, 100])
- R__016: session při příjezdu bere forecast target+deadline (za per-vozidlo
  flagem, fallback defaulty, ruční patch vždy vyhrává) → víkendová session
  s pondělním deadline = v2 solver přirozeně nabije v levných slotech
- tesla_client: + vehicle_state endpoint (odometer v MÍLÍCH → km), collector:
  departure hook, lifespan: job 00:50

Aktivace po nasbírání dat: update asset_vehicle set target_soc_forecast_enabled=true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:06:10 +02:00
Dusan Vojacek
002566ae5f Bazál: odečítat bazénové čerpadlo (telemetry_pool_pump) z baseline učení
All checks were successful
CI and deploy / migration-check (push) Successful in 21s
CI and deploy / deploy (push) Successful in 56s
Pravidlo 15: měřená řízená zátěž nesmí špinit bazální křivku — dosud se
odečítalo jen EV a TČ. Ruční chod čerpadla (vysávání…) i plánovaná filtrace
se nyní přiřazují zařízení, ne bazálu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:27:00 +02:00
Dusan Vojacek
466c15fa84 Bazén: V085→V087 (out-of-order po V086) + seed Shelly Plug S Gen3 home-01
All checks were successful
CI and deploy / migration-check (push) Successful in 16s
CI and deploy / deploy (push) Successful in 1m14s
V088: endpoint shelly_http 172.16.1.15 + asset pool-pump-1 (rated 600 W odhad
— upřesní telemetrie; 480 min/den letní filtrace; schedulable=false =
telemetry-only start, ovládání signálem POOL_PUMP_ON po ověření).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:14:15 +02:00
Dusan Vojacek
02c35f8add Merge: bazénové čerpadlo přes Shelly (telemetrie + signal ovládání)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 00:12:43 +02:00
Dusan Vojacek
60176fc7b2 Tesla Fleet API: čtení SoC po příjezdu k wallboxu
All checks were successful
CI and deploy / deploy (push) Successful in 58s
CI and deploy / migration-check (push) Successful in 16s
- services/tesla_client.py: access token s cache + ROTACE refresh tokenu do
  ems.tesla_token (env jen seed — Tesla refresh token je jednorázový),
  vehicles → vehicle_data?endpoints=charge_state, 408 (spící auto) = tiché
  přeskočení, výběr vozidla dle VIN / jediného na účtu (VIN se auto-naučí)
- hook _patch_session_from_tesla v _on_ev_arrival: PŘED replanem doplní
  soc_at_connect_pct (+ target z charge_limit_soc) do otevřené session přes
  fn_ev_session_apply_patch (rozšířena o soc_at_connect_pct) — energii si
  odvodí fn_planning_site_context (SQL-first); selhání neblokuje replan
- V086: asset_vehicle.vin, api_type='tesla' pro tesla-my (Model Y, home-01),
  singleton ems.tesla_token; R__095: fn_tesla_token_get/upsert,
  fn_tesla_arrival_context, fn_vehicle_set_vin
- config: TESLA_CLIENT_ID/SECRET/REFRESH_TOKEN (prázdné = vypnuto)
- testy parserů; plná sada beze změny

Aktivace: env do /opt/ems-deploy/.env + recreate backendu (docs/tesla-fleet-api.md §Stav).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:29:24 +02:00
Dusan Vojacek
21b3d12955 deploy.sh: stop app kontejnerů PŘED flyway (ne až před buildem)
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 44s
Run 368/369: flyway validate 9,5 min nedostal spojení k db (EOF) — server
zadušený běžícím stackem + buildem. Stop backend/frontend/postgrest hned po
compose config; db zůstává pro flyway. Workflow self-install už funguje
(ověřeno v logu 369: nový skript se použil).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:08:34 +02:00
Dusan Vojacek
620cea8b9b Tesla Fleet API: příprava domény ems.vojacek.eu (cert + public key + callback)
All checks were successful
CI and deploy / migration-check (push) Successful in 33s
CI and deploy / deploy (push) Successful in 1m30s
Doména slouží jen jako veřejná vizitka pro Tesla: Caddy blok vystavuje POUZE
.well-known public key a statickou OAuth callback stránku (zobrazí ?code=,
nic neodesílá), vše ostatní 404 — EMS zůstává na VPN. Certifikát Let's Encrypt
řeší hostovský Caddy automaticky.

deploy/tesla/setup_tesla_domain.sh (spustit NA SERVERU): EC keypair prime256v1
(privátní do /opt/ems-deploy/secrets 0600), public do .well-known, callback,
vypíše Caddy blok. docs/tesla-fleet-api.md: developer portál, partner
registrace, OAuth flow, plán EMS integrace (tesla_client + hook v _on_ev_arrival).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:58:28 +02:00
Dusan Vojacek
0ed6f18e1a EV řízení: zápis Amps-to-use přes journal + watchdog + okamžitý replan po příjezdu
Some checks failed
CI and deploy / migration-check (push) Successful in 39s
CI and deploy / deploy (push) Failing after 9m55s
Bod 1 — write_ev_setpoints reálně (konec TODO stubu):
- reg 15 (0=stop, 6–32 A) z plánu přes _current_limit_for_charger; plná
  journal pipeline (create_modbus_commands → execute, verify job 2 min generic)
- watchdog reg 19=300 s + reg 20=8 A: výpadek EMS → wallbox po 5 min failsafe
  8 A (auto se přes noc nabije); drop-unchanged → zapisuje se jen při změně
- fn_modbus_last_verified_map: + p_asset_type (drop 2-arg; dosud hardcoded
  'inverter' — pro chargery vracela {})
- verify: SELF_SUSTAIN fallback explicitně jen pro asset_type='inverter' —
  mismatch wallboxu nesmí degradovat režim celé site
- journal register_name: mimo inverter platí jméno od volajícího

Bod 2 — telemetry_collector: přechod available→connected spustí fire-and-forget
run_rolling_replan(triggered_by=ev_arrival:<code>) + export_setpoints přes BG
pool — reakce na příjezd ~60 s místo až 15 min.

Bod 3 (Tesla API SoC) čeká na developer credentials.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:51:38 +02:00
Dusan Vojacek
e7b87fbabd Deploy: validate s ignore pending + workflow si sám aktualizuje deploy.sh
Some checks failed
CI and deploy / migration-check (push) Successful in 5m2s
CI and deploy / deploy (push) Failing after 3m27s
deploy.sh validate selhával na nové/změněné repeatable (flyway 12: pending =
error), kterou má hned následující migrate aplikovat → -ignoreMigrationPatterns
'*:pending'. Workflow deploy step nově nejdřív resetne checkout a nainstaluje
ROOT kopii deploy.sh z repa — opravy skriptu se propagují bez ručního zásahu
na serveru (deploy.sh fetch/reset zopakuje idempotentně).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:45:41 +02:00
Dusan Vojacek
cf663ae417 Docs: pool-shelly — architektura, šablony seedů, návrh solver integrace
- šablona insertů endpoint/asset/signal_route (placeholdery IP, výkon, runtime)
- tok ovládání přes fn_signal_enqueue_bool a signal_service
- návrh pool[t] binárky analogicky hp[t] s denním runtime constraintem
- checklist oživení, otevřené otázky (sezónnost, bazál, UI)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:37:57 +02:00
Dusan Vojacek
733224d18d Collector: poll_pool_pumps — 1min telemetrie Shelly bazénu
- nová funkce na konci souboru + jeden řádek v run_telemetry_loop
  (minimální dotyk kvůli souběžným změnám na main)
- čte vw_asset_pool_pump_http_poll, zapisuje fn_telemetry_pool_pump_sample
- při výpadku čtení nic nezapisuje (žádná fabrikovaná nula)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:37:57 +02:00
Dusan Vojacek
7f22311172 Shelly Gen2 RPC klient (httpx) + unit testy
- Switch.GetStatus (output, apower W, aenergy.total Wh), Switch.Set
- jen Gen2 RPC, Gen1 odpověď parser odmítá; timeout, bez retry smyček
- testy: čistý parser + RPC přes httpx.MockTransport (bez sítě)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:37:57 +02:00
Dusan Vojacek
ccdca068a1 DB: bazénové čerpadlo přes Shelly relé (V085)
- ems.asset_pool_pump (endpoint http, rated_power_w, min_run_min,
  daily_runtime_min jako aktuální sezónní hodnota, schedulable)
- ems.telemetry_pool_pump — 1min hypertable (is_on, power_w, energy_wh_total)
- signal_def POOL_PUMP_ON (bool) pro ovládání přes signal infrastrukturu
- fn_telemetry_pool_pump_sample (R__092), vw_asset_pool_pump_http_poll (R__093)
- fn_signal_enqueue_bool (R__094) — SQL-first zařazení bool signálu do fronty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:37:42 +02:00
Dusan Vojacek
4d1313a3bc CI: flyway validate ignoruje pending repeatables
Some checks failed
CI and deploy / migration-check (push) Successful in 1m5s
CI and deploy / deploy (push) Failing after 5m50s
Změněná repeatable (R__047 current_a) je proti prod DB 'pending' a validate
bez ignore patternu selhával — design gate počítal jen s checksum mismatch
verzovaných (ty hlídá ci_check_migration_immutability.sh). Ověřeno lokálně
proti prod DB: Successfully validated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:30:38 +02:00
Dusan Vojacek
5239463699 EV telemetrie: skutečné čtení Teltonika TeltoCharge (konec stub-u)
Some checks failed
CI and deploy / migration-check (push) Failing after 23s
CI and deploy / deploy (push) Has been skipped
poll_ev_chargers četl placeholder ('available'/0 W) — EV spotřeba se nikdy
neodečítala z bazálu a session detekce nefungovala. Nyní: blok registrů 0-40
jedním FC 3 (oficiální protokol rev 0.5), parse_teltocharge_frame (status z
reg 6 → available/preparing/charging/..., výkon reg 38, energie session reg 39,
proud max L1-L3 reg 3-5). Při selhání čtení se vzorek NEzapisuje (fabrikovaný
available by falešně ukončoval session).

fn_telemetry_ev_charger_sample: + p_current_a (drop staré 7-arg signatury).
6 nových testů parseru; plná sada beze změny. Docs: modbus-registers-teltocharge.md.

Po deployi: home-01 ev-charger-1/2 začnou posílat reálná data; bazál se začne
čistit od EV (EMA 00:30); rebuild stats má smysl až po ~2 týdnech čisté historie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:10:46 +02:00
Dusan Vojacek
53e9afb513 Investiční studie v2: POTENCIÁLNÍ výroba místo telemetrie (škrcení 81 %!)
Klíčová oprava (postřeh uživatele): při sell<0 lokality škrtí výrobu
(reg 340 / GEN cutoff) — telemetrie ukázala 357 kWh, predikce 1879 kWh
(96 % minut v derating). Studie nyní používají max(skutečnost, kanonický
forecast per pole) v sell<0 slotech.

Nové výsledky (horní meze): BA81 32 kWh +35/+46 Kč/den (výkon 6.25/12 kW);
KV1 25 kWh +20/+22 Kč/den (stará smlouva); HU1 fixní: 75 Kč/den bez sdílení,
149 Kč/den s EDC sdílením @1.5 Kč distribuce (sdílitelných ~49 kWh/den!);
HU1 spot: 372 Kč/den, sdílení +0. + docs/onboarding-wallbox-tc-2026-06.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:24:49 +02:00
Dusan Vojacek
d47f5f8b87 Studie investic: navýšení baterií BA81/KV1 + HU1 BESS (perfect hindsight nad reálnými daty)
battery_upgrade_study.py: oracle MILP po týdenních oknech s navazujícím SoC,
plné limity (síť, BMS, bateriová cesta střídače, AC strop hybridu, GEN 5 kW
mimo AC strop, gen-cutoff shed pole B). Výsledky viz docstring/report.

hu1_bess_study.py: čistý BESS 128 kWh / 36 kW / AC 40 kW; fixní (BA81) vs
spot (site 5) ceny; EDC sdílecí kanál z BA81 neg-sell přebytku s citlivostí
na distribuci. Klíčové: spot nákup ~7× výnosnější než fixní; EDC sdílení
přidává málo (fixní) až nic (spot — neg buy levnější než distribuce).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:07:04 +02:00
Dusan Vojacek
847015fd48 Přepnutí plánovače na v2 (čisté jádro) — v1 zůstává jako shadow peer
All checks were successful
CI and deploy / deploy (push) Successful in 1m14s
CI and deploy / migration-check (push) Successful in 22s
Podklady: harness +22 % na 6 fixtures (vč. vyřešení Infeasible dne 2026-05-01),
první živé srovnání 11. 6.: v2 o 28.8 Kč lepší (v1 kvůli relax řetězci potlačil
evening push a neprodal špičku 3.92 Kč/kWh). Předletová kontrola: planning_interval
v2 bez NULL, Planning.tsx snapshot parsing defenzivní, exporter čte jen
planning_interval. Rollback: PLANNING_ENGINE_VERSION=v1 v /opt/ems-deploy/.env.

Pozn.: pokud .env na serveru definuje PLANNING_ENGINE_VERSION, přebíjí tento
default — po deployi ověřit build tag aktivního runu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:45:18 +02:00
Dusan Vojacek
e42569f629 Režimy: okamžitá exekuce setpointů po ručním přepnutí módu
All checks were successful
CI and deploy / deploy (push) Successful in 41s
CI and deploy / migration-check (push) Successful in 25s
Control exporter běží jen v minutách 14/29/44/59 — po POST /mode střídač
až ~15 min jel podle starého plánu (uživatel: 'SELF_SUSTAIN se jen přestane
řídit'). Defaulty režimů exporter umí správně (SELF_SUSTAIN: 108/109=max A,
export 0; PRESERVE: lock; CHARGE_CHEAP: max nabíjení bez exportu; MANUAL:
bez zápisu) — chyběl jen trigger. Fix: fire-and-forget export_setpoints
hned po fn_set_mode (chyby do logu, API neblokuje Modbus).

Pozn.: systémové přepnutí mismatch→SELF_SUSTAIN dál čeká na 2min verify tick
— případné zrychlení řešit v notification_service (mimo rozsah).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:35:25 +02:00
Dusan Vojacek
c9409b0666 CI: flyway validate funguje i v container módu runneru
All checks were successful
CI and deploy / migration-check (push) Successful in 34s
CI and deploy / deploy (push) Successful in 1m2s
Root cause rozbitého CI: docker CLI v jobu mluví s hostovským daemonem,
takže -v bind mounty checkoutu ukazovaly na neexistující hostovské cesty
→ flyway dostal prázdné adresáře (applied migration not resolved locally).
Fix: docker create + docker cp (streamuje od klienta) + start/wait/logs.
Cíl /sql, ne /flyway/sql — image tam deklaruje VOLUME, který by kopii zastínil.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:58:00 +02:00
Dusan Vojacek
46d333d561 deploy.sh: stop app kontejnerů před buildem + self-sync skriptu
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped
Slabý server: build s běžícím stackem se dusí (pozorování z provozu) —
před docker compose build zastavit backend/frontend/postgrest (db zůstává).
Self-sync: po git resetu se ROOT/deploy.sh atomicky obnoví z checkoutu
(projeví se příštím během). První instalace nové verze ručně:
install -m 0755 /opt/ems-deploy/app/deploy/deploy.sh /opt/ems-deploy/deploy.sh

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:52:25 +02:00
Dusan Vojacek
634b7d3fb3 CI retrigger po opravě EMS_CI_FLYWAY_URL secretu
Some checks failed
CI and deploy / migration-check (push) Failing after 8m47s
CI and deploy / deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:41:24 +02:00
Dusan Vojacek
c4fe0b713e Merge: Čistý plánovač Fáze 0-3 + FE výkon/responsivita + LATERAL views
Some checks failed
CI and deploy / migration-check (push) Failing after 7m23s
CI and deploy / deploy (push) Has been skipped
- Ekonomický harness (golden gate, economics report, penalty audit)
- Dekompozice planning_engine → services/planning/ (fasáda, chování beze změny)
- solver_v2 (čisté jádro): +22 % vs v1 na fixtures, řeší Infeasible den
- Shadow porovnání v1 vs v2 zapnuto (PLANNING_ENGINE_COMPARE_ENABLED=true,
  aktivní zůstává v1)
- FE: polling/payload/lazy chunks/2 vlny; responsivní grafy, tap-to-pin tooltip
- DB: vw_latest_inverter/ev_charger → LATERAL (fn_site_full_status 1.7 s → ~0.25 s)
- Dokumentace: docs/refactor-clean-planner.md, changelog, audity, delta-triage skill

Testy: 245 passed, 4 xfailed (zdůvodněné stale), 1 předexistující reg340 fail.
Golden gate 7/7. FE build zelený. Migrace: pouze repeatable (immutability ok).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:52:04 +02:00
Dusan Vojacek
5e419f0a5e Deploy: zapnout shadow porovnání plánovače v1 vs v2
PLANNING_ENGINE_COMPARE_ENABLED default true v deploy compose — aktivní
zůstává v1, v2 se počítá paralelně do planning_run.solver_params.comparison.
Přepnutí na v2 později přes PLANNING_ENGINE_VERSION v /opt/ems-deploy/.env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:49:58 +02:00
Dusan Vojacek
ad4b52c9ce Dokumentace refaktoru a delta-triage skill
- docs/refactor-clean-planner.md: plán Fází 0-4, stav, závazná pravidla
  (golden gate), návod nasazení v2 (shadow → vyhodnocení → přepnutí)
- docs/planning-changelog.md: záznam 2026-06-11 (Fáze 0-3 kompletní)
- docs/04-modules/planning.md: sekce Verze enginu v1/v2 + env flagy
- docs/audits/*: stav implementace FE fixů
- .claude/skills/ems-delta-triage: postup triáže neekonomického chování
  (realita vs plán vs shadow peer vs oracle, verdikt s Kč)
- CLAUDE.md: ukazatele na refaktor, solver_v2 a delta-triage v 'Kde hledat co'

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:45:16 +02:00
Dusan Vojacek
b5dbc8cf0a FE: oprava typování getElementsAtEventForMode (chart.js 4.4) + build ověřen
Runtime metoda není ve veřejných typech — typovaný cast v EnergyChart a
SocTuvChart. npm run build zelený; chunking funguje (index 81 kB, vendor-react
177 kB, recharts/nivo/chartjs lazy per route).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:40:53 +02:00
Dusan Vojacek
60f5f77146 Merge FE výkon+responsivita (worktree agent)
Polling 60/15/120 s, telemetry payload dle okna grafu, manualChunks + lazy
routes, 2-vlnové načítání dashboardu (stale data bez blikání), responsivní
výšky grafů, StatePanel mobile, PlanSlotDetail jako sticky řádek, tap-to-pin
tooltip na touch (Chart.js panel / Recharts trigger click), touch targets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:33:11 +02:00
Dusan Vojacek
d767d0abca drobnost: komentář poll intervalu po změně na 15 s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:31:30 +02:00
Dusan Vojacek
1d5b97c65f Výkon: vw_latest_inverter / vw_latest_ev_charger přepis na LATERAL
DISTINCT ON třídilo ~195k/277k řádků hypertable při každém čtení
(fn_site_full_status 1.7 s). LATERAL limit 1 per zařízení jde po PK indexu.
Ověřeno na živé DB: identické výsledky, inverter 508→56 ms, EV 460→75 ms.
EV konektory: discovery za 30 dní (tabulka konektorů neexistuje; mrtvý
konektor po 30 dnech z latest pohledu zmizí — vědomá změna sémantiky).
Sloupce i pořadí beze změny (create or replace view kompatibilní).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:30:44 +02:00
Dusan Vojacek
f2901ef366 responsivita: grid breakpointy karet, ControlPanel max-h 50vh, touch targets a reduced-motion CSS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:28:49 +02:00
Dusan Vojacek
02e0134794 responsivita: touch tooltipy — tap-to-pin panel u Chart.js, trigger click u Recharts
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:28:08 +02:00
Dusan Vojacek
eb360da910 responsivita: StatePanel label nad track na mobilu, Planning detail pod řádkem + min-w tabulky
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:25:22 +02:00
Dusan Vojacek
ca6bd4ab2a responsivita: výšky grafů přes tailwind chart-*, viewport-fit=cover
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:24:10 +02:00
Dusan Vojacek
a8b4342099 Fáze 3.4: router verzí plánovače — v2 zapojeno do shadow porovnání
_solve_dispatch_for_version: 'v2' → services.planning.solver_v2 (čisté jádro),
jinak v1 two-pass; chyby v2 balené do PlannerSolverError (failure pipeline).
Zapojeno do _maybe_add_planner_comparison (peer) i aktivních běhů
run_daily_plan / run_rolling_replan (gated PLANNING_ENGINE_VERSION).

Aktivace shadow: env PLANNING_ENGINE_COMPARE_ENABLED=true (aktivní zůstává v1,
v2 se počítá paralelně, srovnání v planning_run.solver_params.comparison).
Přepnutí: PLANNING_ENGINE_VERSION=v2. Default beze změny — golden 7/7,
plná sada 245 passed (1 předexistující reg340 fail), 4 xfailed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:23:24 +02:00
Dusan Vojacek
293f32cff1 výkon: dashboard ve 2 vlnách (status hned, plán/telemetrie async), stale data bez blikání
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:23:23 +02:00
Dusan Vojacek
7c2669def6 výkon: manualChunks vendor knihoven a lazy route komponenty
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:22:05 +02:00
Dusan Vojacek
90f79d9abe výkon: pomalejší polling (60/15/120 s) a dynamický limit telemetrie 15m
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:21:28 +02:00
Dusan Vojacek
7d9ce5746a Fáze 3.3: unit testy solver_v2 (11 testů)
Tvrdá pravidla (neg-buy/neg-sell bloky, arb floor, curtail jen A), arbitráž
levná noc → drahý večer, režimy PRESERVE/CHARGE_CHEAP/SELF_SUSTAIN, EV deadline
vč. placeného slacku při nesplnitelném deadline. Vše zelené, plná sada beze změny.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:20:45 +02:00
Dusan Vojacek
90a85b2727 Fáze 3.2: solver_v2 — čisté ekonomické jádro plánovače
services/planning/solver_v2.py: MILP s objective = reálné peníze (cash +
degradace − terminal SoC value z DB faktoru). Tvrdá pravidla: bilance, SoC
dynamika, breaker (tvrdý), curtail jen A, GEN cutoff binárka, neg-buy/neg-sell
export bloky, export z baterie ⇒ arb floor (p.19), zákaz současného imp+exp,
EV deadline (placený slack 50 Kč/kWh místo infeasibility), TUV look-ahead,
provozní režimy. SQL masky allow_* vědomě ignorovány (heuristika, ne fyzika).

solver_v2_eval.py: v2 vs v1 na golden fixtures (SoC-fér):
  v2 lepší na VŠECH 5 řešitelných (+231.5 Kč ≈ +22 %), extreme_neg_buy den
  v1=INFEASIBLE → v2 OK (−674.5 Kč). Časy 0.4–10 s (2× na time limitu — TODO).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:19:32 +02:00
Dusan Vojacek
368291e562 Audity frontendu: výkon + responsivita (2026-06-11)
Výkon: dominují DB read-modely — fn_plan_current_bundle 3.8 s,
fn_site_full_status 1.7 s (měřeno na živé DB); dále payloady, polling,
chybějící virtualizace Planning tabulky, bundle 1.2 MB bez chunking.
Responsivita: pevné výšky grafů, tooltip × StatePanel/tabulka kolize na touch,
StatePanel grid, breakpointy. Plné detaily a fixy v docs/audits/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:15:02 +02:00
Dusan Vojacek
ec13c2ad6e Fáze 2/3: rozšířený penalty audit + prototyp čistého jádra
Penalty audit (6 fixtures vč. evening_push a extreme_neg_buy):
- stejných 16/26 penalt mrtvých i na rozšířeném pokrytí
  (vč. EVENING_PUSH_Z_EXPORT_BONUS=2500 na evening-push dni)
- žádná penalta nezpůsobuje Infeasible 2026-05-01 (strukturální problém)
- Σpenalty 7978 Kč vs cashflow −614 Kč

clean_core_prototype.py: čistý ekonomický MILP (bez heuristických penalt) na
IDENTICKÝCH vstupech fixtures vs golden snapshoty současného plánovače:
- lepší na všech 5 řešitelných fixtures, celkem +266 Kč (+25 %) za horizonty
- extrémní den 2026-05-01: current INFEASIBLE → clean OK (−713 Kč zisk)
- férové: současné plány mají hp/ev setpointy 0, čistý dispatch srovnání

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:02:17 +02:00
Dusan Vojacek
9a2229641d Fáze 2.1: 4 zastaralé testy → expectedFailure; +2 fixtures vč. Infeasible reproduceru
Analýza (agent + ručně): všechny 4 failující testy vynucují heuristické chování
před retry-chain v5; současné chování je ekonomicky správné nebo jde o korektní
fallback. Scénáře zachovány s @unittest.expectedFailure + zdůvodněním —
přepsat na ekonomické asserty ve Fázi 3. Suite: 120 passed, 4 xfailed.

Nové golden fixtures home-01: 2026-05-01 extreme_neg_buy (buy −13.26;
ZACHYCENO: solver Infeasible po celém relax řetězci — zmrazeno jako golden
failure snapshot), 2026-05-25 evening_push. Golden replay i penalty audit
umí solver_error výsledky (penalta měnící feasibility se zviditelní).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:56:12 +02:00
Dusan Vojacek
0dc2e1df96 Fáze 2.2: penalty audit — 16 z 26 penalt mrtvých na golden fixtures
scripts/harness/penalty_audit.py: vynulování každé ekonomické konstanty →
replay 4 golden fixtures → Δcashflow / Δpenalty / změněné sloty.

Výsledek (penalty_audit_baseline_2026-06-11.txt):
- 16/26 penalt bez jakéhokoli vlivu na 4 reprezentativních scénářích
- aktivní penalty silně interagují (odstranění jedné zvedne binding jiných
  o stovky Kč — POS_SELL_PRE_NEG +481, PRE_NEG_BUY_SOC_CEILING −1480)
- Σpenalty 2140 Kč vs cashflow −440 Kč na baseline

CLAUDE.md: doplněna struktura services/planning/ a harness do tabulky adresářů.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:39:55 +02:00
Dusan Vojacek
cb6afbb3fd Fáze 1.5: extrakce 88 pre-solver heuristik do services/planning/heuristics.py
SoC série, neg-sell fáze/okna, evening push, pre-neg logika — čistý přesun,
fasáda v planning_engine.py beze změny chování (golden 5/5, baseline faily
beze změny). Roztroušené konstanty MORNING_PRENEG_* doplněny do constants.py.

planning_engine.py: 6345 → 3925 řádků (zbývá: solver, orchestrace, compare).
heuristics.py nese warning: hlavní kandidáti na prune ve Fázi 2/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:32:47 +02:00
Dusan Vojacek
dcbb5de98c Fáze 1.3+1.4: extrakce forecast korekce a DB vrstvy plánovače
- services/planning/forecast.py: compute_correction_factor, apply_forecast_correction
- services/planning/db_io.py: _ev_session_from_json, _load_site_context,
  _load_previous_plan_charge_commitment_prev_w, _load_slots, _build_slot_inputs,
  _save_planning_run, _save_failed_planning_run
- .claude/settings.json: projektový allowlist (autonomní běhy bez promptů)

Fasáda beze změny chování; golden 5/5, baseline faily beze změny.
planning_engine.py: 6345 → 5717 řádků.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:39:55 +02:00
Dusan Vojacek
d83917da51 Fáze 1.2: extrakce typů a časových utilit do services/planning/types.py
PlannerSolverError, PlanningSlot, DispatchResult, SOC_MIN_RELAX_LOOKAHEAD_SLOTS,
_timestamptz_from_db, _slot_float_nullable, _prague_dow_hour, _prague_calendar_date,
_prague_hour, _parse_json_dt, _current_slot_start. Fasáda v planning_engine.py,
beze změny chování (golden 5/5, baseline 4+1 faily beze změny).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:34:19 +02:00
Dusan Vojacek
4ee5cebf2a Fáze 1.1: extrakce konstant plánovače do services/planning/constants.py
Čistý přesun 57 konstant (vč. SOLVER_RELAX_STEPS) z planning_engine.py;
engine je importuje zpět (fasáda, beze změny chování). Golden replay 5/5,
unit testy beze změny vůči baseline (4+1 předexistující faily).

Ekonomické penalty/váhy tím získaly jedno místo — kandidáti na DB ve Fázi 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:09:10 +02:00
Dusan Vojacek
484f1f85fc Fáze 0: ekonomický regresní harness plánovače
- scripts/harness/extract_fixtures.py: extrakce vstupů solveru
  (fn_planning_site_context + fn_load_planning_slots_full) do JSON fixtures
- backend/tests/test_golden_replay.py: golden gate — replay fixtures přes
  solve_dispatch_two_pass, bit-perfektní diff proti snapshotům (GOLDEN_UPDATE=1
  pro vědomou regeneraci); 4 scénáře: home-01 neg-sell extrém / normal, BA81, KV1
- scripts/harness/economics_report.py: actual (audit_interval) vs oracle MILP
  (perfect hindsight, čistá ekonomika bez heuristických penalt), SoC-adjusted

Baseline home-01 2026-05-12..06-09: GAP 2185 Kč / 29 dní (~27 %).
Známý stav: 4/124 testů test_planning_dispatch_milp.py failuje už na main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:48:13 +02:00
Dusan Vojacek
edc8ae9774 prej final final v2 verze
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-06-07 00:05:46 +02:00
Dusan Vojacek
50ac40868d fakt me to nebavi furt jsou tam chyby
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-06-06 23:58:01 +02:00
Dusan Vojacek
b7903db714 dasli fix
Some checks failed
CI and deploy / migration-check (push) Failing after 23s
CI and deploy / deploy (push) Has been skipped
2026-06-06 23:47:12 +02:00
Dusan Vojacek
3ad5bec76b aa zas oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-06-06 23:25:36 +02:00
Dusan Vojacek
37df01d43c dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-06-06 23:12:08 +02:00
Dusan Vojacek
3161421d5c skill pro debug
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-06-06 22:41:56 +02:00
Dusan Vojacek
36cb06b9d0 Branch 5: dynamický terminal SoC factor při future neg buy
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-06-06 22:38:05 +02:00
Dusan Vojacek
0f7dc6ed94 Branch 4: BA81 GEN cutoff audit + exekuce při sell<0
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-06-06 22:36:27 +02:00
Dusan Vojacek
a7879f1141 Branch 3: charge-slot-budget v R__063 + odstranit v58 pro BA81/KV1 + fixed evening push
Some checks failed
CI and deploy / migration-check (push) Failing after 25s
CI and deploy / deploy (push) Has been skipped
2026-06-06 22:32:48 +02:00
Dusan Vojacek
09bca0a903 Branch 2: home-01 neg-večer — export k reserve_soc, fix pos_sell_pre_neg_buy + oddělit evening_push od prep relax
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-06-06 22:28:48 +02:00
Dusan Vojacek
2a963c9793 Branch 1: failed run journal + bisect Infeasible + granulární relaxace (bez vypnutí evening push)
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-06-06 22:23:59 +02:00
Dusan Vojacek
1429d402e5 zdokumentovani noveho pohleud na planovani nabijeni
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-06-01 19:53:04 +02:00
Dusan Vojacek
d44a2cbb44 dalsi
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
2026-06-01 19:20:27 +02:00
Dusan Vojacek
96adbff9ea nakup ve spicce aby prodal lenvneji, ale nemam jak otestovat poac uz bude po slotu (home01)
Some checks failed
CI and deploy / migration-check (push) Failing after 34s
CI and deploy / deploy (push) Has been skipped
2026-06-01 19:17:55 +02:00
Dusan Vojacek
63eff96c5f zas oprava KV1 a BA81
Some checks failed
CI and deploy / migration-check (push) Failing after 41s
CI and deploy / deploy (push) Has been skipped
2026-06-01 19:04:11 +02:00
Dusan Vojacek
0dcf11d471 oprava ranniho nenabijeni
Some checks failed
CI and deploy / migration-check (push) Failing after 50s
CI and deploy / deploy (push) Has been skipped
2026-06-01 18:50:03 +02:00
Dusan Vojacek
430e081841 oprave vercerniho nevyprodeje
Some checks failed
CI and deploy / migration-check (push) Failing after 26s
CI and deploy / deploy (push) Has been skipped
2026-06-01 18:24:57 +02:00
Dusan Vojacek
5d06f49d2b oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-31 00:13:44 +02:00
Dusan Vojacek
111f51c06c zas oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-05-31 00:07:43 +02:00
Dusan Vojacek
8950fafba2 oprava home-01: Infeasible při rolling hysteréze push (v53)
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-31 00:00:47 +02:00
Dusan Vojacek
578cf315e2 urpava KV1 vyliti v maxu v noci
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped
2026-05-30 23:47:44 +02:00
Dusan Vojacek
a03b45d4a9 oprava zbytecneho curtailu A
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-05-30 23:23:17 +02:00
Dusan Vojacek
830aa7a4cc oprava nevyberu maximalnich sell slotu (sahal i na zitejsi vecer)
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-30 22:56:28 +02:00
Dusan Vojacek
4f67aad4d8 a dalsi pokus o opravu
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped
2026-05-30 22:15:40 +02:00
Dusan Vojacek
96d0d52b07 oprava battery hold
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-30 22:11:03 +02:00
Dusan Vojacek
5208e035a4 a dalsi oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped
2026-05-30 22:02:02 +02:00
Dusan Vojacek
d3e9caf0fb dalsi
Some checks failed
CI and deploy / migration-check (push) Failing after 23s
CI and deploy / deploy (push) Has been skipped
2026-05-29 23:34:16 +02:00
Dusan Vojacek
308c24f029 dalsi
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-05-29 23:24:03 +02:00
Dusan Vojacek
b73c3323e1 oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 28s
CI and deploy / deploy (push) Has been skipped
2026-05-29 23:04:27 +02:00
Dusan Vojacek
877f5b6180 tuning prodeje
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
2026-05-29 22:45:02 +02:00
Dusan Vojacek
230351b38a oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 36s
CI and deploy / deploy (push) Has been skipped
2026-05-29 22:26:52 +02:00
Dusan Vojacek
88df09640c Use observed SoC for neg-prep cushion and evening drain (v40).
Some checks failed
CI and deploy / migration-check (push) Failing after 26s
CI and deploy / deploy (push) Has been skipped
Pre-neg forecast cushion and evening push before negative-sell days now use telemetry SoC instead of chaining LP targets across days, so the planner does not stop discharging early when BMS is higher than the model.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:20:05 +02:00
Dusan Vojacek
a7dff75e58 Add export plan guard to block Deye export against plan.
Some checks failed
CI and deploy / migration-check (push) Failing after 39s
CI and deploy / deploy (push) Has been skipped
Force PASSIVE/no-export when sell is negative or export_mode is NONE,
and alert NEG_SELL_EXPORT in plan_actual_slot_guard when export still occurs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:14:52 +02:00
Dusan Vojacek
620a557a89 Align evening push with peak-band candidates and dynamic Wh budget.
Some checks failed
CI and deploy / migration-check (push) Failing after 23s
CI and deploy / deploy (push) Has been skipped
Restore _evening_peak_export_indices filter so push slots are chosen from
profitable peak-band nights, then ranked by sell until the Wh budget is
exhausted—not all profitable night slots and not a fixed top-3. Docs and
tests match v39 SoC balance tag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:10:27 +02:00
Dusan Vojacek
ba0b55bf10 Fix SoC balance on battery export and improve evening push (v39).
Some checks failed
CI and deploy / migration-check (push) Failing after 38s
CI and deploy / deploy (push) Has been skipped
SoC continuity now deducts only bd (ge_bat was double-counted via energy
balance), which stopped the plan from draining ~2× faster than BMS during
evening BATTERY_SELL. Also ships dynamic evening push budget + rolling
hysteresis (v38), drops unused fn_soc_tracking_bundle, and adds tests/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:04:48 +02:00
Dusan Vojacek
52e4b68789 ski battery charge u sell
Some checks failed
CI and deploy / migration-check (push) Failing after 26s
CI and deploy / deploy (push) Has been skipped
2026-05-28 23:22:57 +02:00
Dusan Vojacek
4e5de5df90 dalsi oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped
2026-05-27 07:45:50 +02:00
Dusan Vojacek
8c7072da07 oprava BA ranni export
Some checks failed
CI and deploy / migration-check (push) Failing after 23s
CI and deploy / deploy (push) Has been skipped
2026-05-27 07:30:08 +02:00
Dusan Vojacek
19108002ca oprava KV 1
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-26 14:57:52 +02:00
Dusan Vojacek
96b16b9ff9 oprava vecerniho nevybijei
Some checks failed
CI and deploy / migration-check (push) Failing after 26s
CI and deploy / deploy (push) Has been skipped
2026-05-26 14:34:39 +02:00
Dusan Vojacek
398e658d16 cileni k vybiti pred ranem kdy nabiju z fve
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-26 14:25:12 +02:00
Dusan Vojacek
d1ba864fc0 oprava ranniho nabijeni a oprava bodu T
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-26 14:09:19 +02:00
Dusan Vojacek
58b0a2f882 implementace dynamickeho bodu T (kde se rodpojuje PV A)
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-05-26 13:28:31 +02:00
Dusan Vojacek
a53bcd0b81 dokumentace planu
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-26 13:00:33 +02:00
Dusan Vojacek
94eb256598 planovac reesi load first
Some checks failed
CI and deploy / migration-check (push) Failing after 21s
CI and deploy / deploy (push) Has been skipped
2026-05-26 09:05:33 +02:00
Dusan Vojacek
b4e5fc5040 uprava aby rano prodaval do site pred sell < 0 oknem
Some checks failed
CI and deploy / migration-check (push) Failing after 20s
CI and deploy / deploy (push) Has been skipped
2026-05-26 08:29:05 +02:00
Dusan Vojacek
da79eec077 fix FE
Some checks failed
CI and deploy / migration-check (push) Failing after 20s
CI and deploy / deploy (push) Has been skipped
2026-05-26 08:10:54 +02:00
Dusan Vojacek
91a9bef3d7 implementace co nejdrive dosazeni SOC na home-01 a umozneni plneho socu n slotu ped koncem sell < 0
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-26 08:07:00 +02:00
Dusan Vojacek
8494ea26de nerezta PV A pri prodeji z baterie
Some checks failed
CI and deploy / migration-check (push) Failing after 28s
CI and deploy / deploy (push) Has been skipped
2026-05-26 07:34:52 +02:00
Dusan Vojacek
25c864db61 protazeni exportniho okna az do rana pred vyrobu FVE (prichazeli sme o prilezitosti mezi pulnoci a ranem)
Some checks failed
CI and deploy / migration-check (push) Failing after 23s
CI and deploy / deploy (push) Has been skipped
2026-05-26 00:00:06 +02:00
Dusan Vojacek
b03f08d3a0 prechazeni omezeni PV A u home-01
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-25 23:28:47 +02:00
Dusan Vojacek
18ace46ea9 fix spravneho prodeje do site
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-25 13:44:30 +02:00
Dusan Vojacek
2e27c8c5de oprava exportu bateir do site vecer
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped
2026-05-25 12:20:51 +02:00
Dusan Vojacek
91af5c76c2 fix max sell z baterky
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-25 11:59:03 +02:00
Dusan Vojacek
c6074e9c74 nastavitelny max sollar dle stridace (ulozeno v DB)
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-25 11:25:29 +02:00
Dusan Vojacek
e06f76b9ff uprava PV omeznovani
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-25 11:08:01 +02:00
Dusan Vojacek
f1a4dbd7e7 zruseni fixnich konstant
Some checks failed
CI and deploy / migration-check (push) Failing after 20s
CI and deploy / deploy (push) Has been skipped
2026-05-25 09:41:06 +02:00
Dusan Vojacek
37a525cb4f predvybiti baterky
Some checks failed
CI and deploy / migration-check (push) Failing after 20s
CI and deploy / deploy (push) Has been skipped
2026-05-25 03:09:33 +02:00
Dusan Vojacek
b8e47e2623 n
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-25 03:00:51 +02:00
Dusan Vojacek
f90004142c a zase dalsi
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-25 02:53:25 +02:00
Dusan Vojacek
0a0668000b x
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-25 02:41:36 +02:00
Dusan Vojacek
a1270dcda3 neverim ze to pomoze
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-25 02:37:04 +02:00
Dusan Vojacek
4beb8cf99f uz sem zaoufalej
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-25 02:13:41 +02:00
Dusan Vojacek
161b463367 a dalsi
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-25 01:59:33 +02:00
Dusan Vojacek
a2a35981a1 stopadesaty fix
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-05-25 01:52:08 +02:00
Dusan Vojacek
5fb4c10ff6 posledni dnesni fix
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-25 01:46:06 +02:00
Dusan Vojacek
254508fe1a dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-25 01:27:33 +02:00
Dusan Vojacek
095676e3b1 revert a nove upravy
Some checks failed
CI and deploy / migration-check (push) Failing after 29s
CI and deploy / deploy (push) Has been skipped
2026-05-25 01:05:23 +02:00
Dusan Vojacek
67d34aba41 Revert "dalsi a dalsi fix"
This reverts commit b03855b3d1.
2026-05-25 01:00:00 +02:00
Dusan Vojacek
b46da6b2dc Revert "a dalsi fix"
This reverts commit 7036bcfdb8.
2026-05-25 01:00:00 +02:00
Dusan Vojacek
7036bcfdb8 a dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-25 00:47:06 +02:00
Dusan Vojacek
b03855b3d1 dalsi a dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 21s
CI and deploy / deploy (push) Has been skipped
2026-05-25 00:31:47 +02:00
Dusan Vojacek
9ba65ea6bb a dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-25 00:10:58 +02:00
Dusan Vojacek
b844a9182f dalsi fix home-01
Some checks failed
CI and deploy / migration-check (push) Failing after 26s
CI and deploy / deploy (push) Has been skipped
2026-05-24 23:19:54 +02:00
Dusan Vojacek
8bef1c6da6 prepsani s opusem dle planu
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-24 22:44:21 +02:00
Dusan Vojacek
2d021b15c3 dalsi fix zapornoeho sellu u home-01
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-24 20:22:11 +02:00
Dusan Vojacek
9a15a4c618 fix home-01 prodej pri zaporu
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-24 18:15:24 +02:00
Dusan Vojacek
747a5bed08 fix BA cutoff
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-24 16:58:45 +02:00
Dusan Vojacek
9d31b19ec6 ladime
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-24 16:36:30 +02:00
Dusan Vojacek
c43bd0a6c6 dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-24 12:11:37 +02:00
Dusan Vojacek
a3c4af3573 fix solveru
Some checks failed
CI and deploy / migration-check (push) Failing after 16s
CI and deploy / deploy (push) Has been skipped
2026-05-24 11:45:10 +02:00
Dusan Vojacek
fb0d947af6 fxi bA81 infeasable
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-24 11:21:12 +02:00
Dusan Vojacek
bd06779fe5 fix BA a KV nefunkcni vecerni prodej
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped
2026-05-24 10:49:35 +02:00
Dusan Vojacek
ce571a93fa uz lepsi ale nabije v zaporu jen na 92%, odstranena nejaka konstanta
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-23 23:41:52 +02:00
Dusan Vojacek
7ff2abc7e0 chjo2
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-23 23:28:50 +02:00
Dusan Vojacek
61a58a62b1 chjo
Some checks failed
CI and deploy / migration-check (push) Failing after 21s
CI and deploy / deploy (push) Has been skipped
2026-05-23 23:07:25 +02:00
Dusan Vojacek
904c318532 preorita nabijeni pred skrcenim
Some checks failed
CI and deploy / migration-check (push) Failing after 18s
CI and deploy / deploy (push) Has been skipped
2026-05-23 22:50:13 +02:00
Dusan Vojacek
645f48036d dalsi a dalsi oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-23 22:41:00 +02:00
Dusan Vojacek
0f922c91f5 dalsi oprava
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
2026-05-23 22:30:46 +02:00
Dusan Vojacek
dbc004a949 fix refaktoru
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-23 22:20:25 +02:00
Dusan Vojacek
e3e5fc138c velky refaktor - sladeni planovani LP aby pocital s realnym max sell/buy co pusti stridac
Some checks failed
CI and deploy / migration-check (push) Failing after 21s
CI and deploy / deploy (push) Has been skipped
2026-05-23 21:54:23 +02:00
Dusan Vojacek
b44f74b249 HU BESS
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-23 21:35:36 +02:00
Dusan Vojacek
da52cf168b dalsi pokus o fix nevyliti baterky pred zapornou cenou
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped
2026-05-23 20:20:10 +02:00
Dusan Vojacek
1ec92bdf79 dalsi
Some checks failed
CI and deploy / migration-check (push) Failing after 21s
CI and deploy / deploy (push) Has been skipped
2026-05-23 00:34:52 +02:00
Dusan Vojacek
a52be1b792 dalsi pokusy
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-23 00:06:30 +02:00
Dusan Vojacek
8845350c0b zase upravujeme planovani hlavne pro home-01
Some checks failed
CI and deploy / migration-check (push) Failing after 21s
CI and deploy / deploy (push) Has been skipped
2026-05-22 23:47:49 +02:00
Dusan Vojacek
f157c10480 dalsi snaha o fix
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped
2026-05-22 23:05:45 +02:00
Dusan Vojacek
0c4de4e5b9 fix infeasable na home-01
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-22 16:09:11 +02:00
Dusan Vojacek
9cf7708909 fix infeasable
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-05-22 15:55:25 +02:00
Dusan Vojacek
c5525c729f oprava KV1 nabijeni rano misto prodeje
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-22 15:36:56 +02:00
Dusan Vojacek
f960e08307 uprava zbytecne setreni baterie a brani zadraho ze site
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped
2026-05-22 15:26:33 +02:00
Dusan Vojacek
cb638b9302 fix prodeje za malo z pole b
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-22 15:14:06 +02:00
Dusan Vojacek
2ebc48f813 fix maxu u baterky pri prodeji
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-22 08:00:34 +02:00
Dusan Vojacek
7b25640557 fix max limitu
Some checks failed
CI and deploy / migration-check (push) Failing after 15s
CI and deploy / deploy (push) Has been skipped
2026-05-22 07:54:56 +02:00
Dusan Vojacek
fff7fdb7c4 dal load first
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-21 17:58:52 +02:00
Dusan Vojacek
d9ecc70980 dalsi pokus o load first
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-21 17:52:10 +02:00
Dusan Vojacek
7c63fed296 implementace load first
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-21 17:29:09 +02:00
Dusan Vojacek
e295e55770 doladeni odpoledniho dobiti
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
2026-05-21 16:18:30 +02:00
Dusan Vojacek
c9149babd3 LP first zjednoduseni
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-21 15:41:26 +02:00
Dusan Vojacek
649c9e9510 uz me to nebavi
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
2026-05-21 15:17:09 +02:00
Dusan Vojacek
fc0761fb2a dalsi ladeni
Some checks failed
CI and deploy / migration-check (push) Failing after 21s
CI and deploy / deploy (push) Has been skipped
2026-05-21 15:04:24 +02:00
Dusan Vojacek
66834ddfa6 dalsi rozvolneni at vic jedeme arbitraz
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-21 14:54:46 +02:00
Dusan Vojacek
3b4d54dcc7 fix planning
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
2026-05-21 14:18:21 +02:00
Dusan Vojacek
739249a244 dalsi ladenik
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-21 14:10:22 +02:00
Dusan Vojacek
ba1cdcbee4 dalsi fixy
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-21 13:44:13 +02:00
Dusan Vojacek
52bedcf67d uprava UI pro planovani
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-21 13:22:33 +02:00
Dusan Vojacek
b78597fdda uprava vypoctu slotu
Some checks failed
CI and deploy / migration-check (push) Failing after 16s
CI and deploy / deploy (push) Has been skipped
2026-05-21 12:36:03 +02:00
Dusan Vojacek
08f1b6741a zasadni uprava LP planneru
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped
2026-05-21 11:18:09 +02:00
Dusan Vojacek
d984716f69 speedup zalozka planning
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-21 10:37:32 +02:00
Dusan Vojacek
eb425a26f2 narovnani spravneho rezimu - nastavenim charge A
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-21 10:23:53 +02:00
Dusan Vojacek
44a06b6288 fix ranniho neprodeje do site
Some checks failed
CI and deploy / migration-check (push) Failing after 42s
CI and deploy / deploy (push) Has been skipped
2026-05-21 10:02:19 +02:00
Dusan Vojacek
27323fd77a fix nabijeni z gridu u fixnich tarifu
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-16 16:38:45 +02:00
Dusan Vojacek
49d0aa68a2 dalsi pokus o opravu
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-16 16:09:03 +02:00
Dusan Vojacek
a17c22d475 dalsi fix - chtel drzet baterii prakticky porad a neprodat ani nejeet passive mode
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-16 15:52:14 +02:00
Dusan Vojacek
1426c0e153 pry oprava uplne chybneho rizeni (prodaval za levneji nez nakoupil)
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped
2026-05-16 15:39:07 +02:00
Dusan Vojacek
7490ac3d70 planner v2 vc. porovnani
Some checks failed
CI and deploy / migration-check (push) Failing after 20s
CI and deploy / deploy (push) Has been skipped
2026-05-15 23:03:32 +02:00
Dusan Vojacek
d89d8b1e3a fix cyklovani
Some checks failed
CI and deploy / migration-check (push) Failing after 26s
CI and deploy / deploy (push) Has been skipped
2026-05-15 17:47:20 +02:00
Dusan Vojacek
30f16a14c2 fix toolu
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-12 22:41:35 +02:00
Dusan Vojacek
851ec2b637 uprava toolu pro battery sizing
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
2026-05-12 21:49:32 +02:00
Dusan Vojacek
64327af8e0 fix KV1/BA81 cyklovani
Some checks failed
CI and deploy / migration-check (push) Failing after 17s
CI and deploy / deploy (push) Has been skipped
2026-05-06 12:50:05 +02:00
Dusan Vojacek
a5184ec42f dalsi fix
Some checks failed
CI and deploy / migration-check (push) Failing after 29s
CI and deploy / deploy (push) Has been skipped
2026-05-05 12:18:27 +02:00
Dusan Vojacek
ab80d13ecb dalsi fix forecat tuningu
Some checks failed
CI and deploy / migration-check (push) Failing after 19s
CI and deploy / deploy (push) Has been skipped
2026-05-05 12:13:07 +02:00
Dusan Vojacek
0d2839d6db fix flyway
Some checks failed
CI and deploy / migration-check (push) Failing after 10s
CI and deploy / deploy (push) Has been skipped
2026-05-05 10:54:16 +02:00
Dusan Vojacek
d54579e3b1 fix build
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
2026-05-05 10:49:40 +02:00
Dusan Vojacek
5b383e9028 sjednoceni forecastu
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
2026-05-05 10:42:49 +02:00
459f33d55c Merge pull request 'dalsi pokus ladeni' (#7) from refactor-control-monolith into main
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
Reviewed-on: #7
2026-05-04 20:12:21 +02:00
Dusan Vojacek
8a3a49806b dalsi pokus ladeni
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 17s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-04 20:11:50 +02:00
a3afd392d3 Merge pull request 'fix chargedischarge A' (#6) from refactor-control-monolith into main
Some checks failed
CI and deploy / migration-check (push) Failing after 22s
CI and deploy / deploy (push) Has been skipped
Reviewed-on: #6
2026-05-04 19:39:46 +02:00
Dusan Vojacek
b35f292295 fix chargedischarge A
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 25s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-04 19:37:42 +02:00
e44cd013f4 Merge pull request 'refactor-control-monolith' (#5) from refactor-control-monolith into main
Some checks failed
CI and deploy / migration-check (push) Failing after 9s
CI and deploy / deploy (push) Has been skipped
Reviewed-on: #5
2026-05-04 19:15:20 +02:00
Dusan Vojacek
6471467bc5 fix
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 13s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-04 19:14:52 +02:00
Dusan Vojacek
ba53fe5bfc fix 2026-05-04 19:10:15 +02:00
87fc9b41cf Merge pull request 'refactor-control-monolith' (#4) from refactor-control-monolith into main
Some checks failed
CI and deploy / migration-check (push) Failing after 13s
CI and deploy / deploy (push) Has been skipped
Reviewed-on: #4
2026-05-04 19:07:17 +02:00
Dusan Vojacek
335c413232 planner battery tuning
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 15s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-04 19:06:04 +02:00
Dusan Vojacek
bcb05d4896 tuning palnneru 2026-05-04 19:04:48 +02:00
Dusan Vojacek
405e832f8d doplneni dokumentace provozcnih rezimu 2026-05-03 22:46:16 +02:00
b022311dec Merge pull request 'refactor export limit semantics' (#3) from refactor-control-monolith into main
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
Reviewed-on: #3
2026-05-03 22:29:33 +02:00
Dusan Vojacek
e8eb867a2a refactor export limit semantics
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 15s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-03 22:24:35 +02:00
7711640a4b Merge pull request 'refactor-control-monolith' (#2) from refactor-control-monolith into main
Some checks failed
CI and deploy / migration-check (push) Failing after 24s
CI and deploy / deploy (push) Has been skipped
Reviewed-on: #2
2026-05-02 20:14:53 +02:00
Dusan Vojacek
349a15e96a update control package facade docs
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 12s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-02 19:57:23 +02:00
Dusan Vojacek
6129677756 refactor control export orchestrator 2026-05-02 19:56:32 +02:00
Dusan Vojacek
6cacf523a2 refactor deye inverter control 2026-05-02 19:54:54 +02:00
Dusan Vojacek
44cd7f986a refactor modbus verify workflow 2026-05-02 19:51:41 +02:00
Dusan Vojacek
53288d130a refactor control output writers 2026-05-02 19:47:12 +02:00
Dusan Vojacek
abe4255f88 refactor modbus command journal 2026-05-02 19:45:22 +02:00
Dusan Vojacek
55ccf06627 refactor control repository access 2026-05-02 19:42:58 +02:00
Dusan Vojacek
0ca1bed0fd refactor control setpoint calculations 2026-05-02 19:40:16 +02:00
Dusan Vojacek
6d6341cde8 refactor control exporter helpers 2026-05-02 19:35:41 +02:00
e2f77eda14 Merge pull request 'gpt5.5 - odladeni dokumentace dle kodu' (#1) from docs-sync-with-implementation into main
Some checks failed
CI and deploy / migration-check (push) Failing after 12s
CI and deploy / deploy (push) Has been skipped
Reviewed-on: #1
2026-05-02 19:18:34 +02:00
Dusan Vojacek
02f0ab66e4 gpt5.5 - odladeni dokumentace dle kodu
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 27s
CI and deploy / deploy (pull_request) Has been skipped
2026-05-02 19:17:04 +02:00
241 changed files with 85961 additions and 3964 deletions

63
.claude/settings.json Normal file
View File

@@ -0,0 +1,63 @@
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"mcp__postgres-ems__query",
"Skill(update-config)",
"Bash(claude mcp *)",
"Bash(python3 *)",
"Bash(python *)",
"Bash(pytest *)",
"Bash(EMS_DB_DSN=*)",
"Bash(GOLDEN_UPDATE=*)",
"Bash(git *)",
"Bash(ls *)",
"Bash(ls)",
"Bash(cat *)",
"Bash(cat)",
"Bash(grep *)",
"Bash(rg *)",
"Bash(find *)",
"Bash(sed *)",
"Bash(awk *)",
"Bash(head *)",
"Bash(tail *)",
"Bash(wc *)",
"Bash(sort *)",
"Bash(uniq *)",
"Bash(diff *)",
"Bash(du *)",
"Bash(mkdir *)",
"Bash(cp *)",
"Bash(mv *)",
"Bash(touch *)",
"Bash(echo *)",
"Bash(which *)",
"Bash(pwd)",
"Bash(cd *)",
"Bash(export *)",
"Bash(env *)",
"Bash(docker ps*)",
"Bash(docker logs *)",
"Bash(jq *)",
"Bash(curl http://localhost*)",
"Bash(curl http://127.0.0.1*)"
],
"ask": [
"Bash(git push*)",
"Bash(docker compose down*)",
"Bash(docker compose rm*)"
],
"deny": [
"Bash(rm -rf /*)",
"Bash(rm -rf ~*)",
"Bash(git reset --hard*)",
"Bash(git clean*)"
]
}
}

View File

@@ -0,0 +1,63 @@
---
name: ems-delta-triage
description: Triáž neekonomického chování plánovače po nasazení — vysvětlit PROČ plán udělal co udělal, porovnat v1 vs v2 (shadow), vyčíslit ztrátu proti oracle. Použít když uživatel hlásí "divné/neekonomické chování", "proč to v X hodin nabíjelo/exportovalo", nebo chce vyhodnotit shadow data v1 vs v2.
---
# EMS delta-triáž (v1 vs v2 vs realita vs oracle)
Cíl: z konkrétního dne/situace vyrobit vysvětlení s čísly, ne dojmy. Vždy
pracuj v pořadí: (1) co se REÁLNĚ stalo, (2) co chtěl plán, (3) co chtěl peer
(shadow), (4) co bylo optimum, (5) proč se liší.
## 0. Vstupy od uživatele
site code (home-01/BA81/KV1/…), den či časové okno (Prague), co je „divné".
## 1. Realita (audit) — MCP `query` na `user-postgres-ems`
```sql
select interval_start, actual_grid_power_w, actual_battery_power_w,
actual_battery_soc_pct, actual_pv_power_w, actual_load_power_w,
actual_cost_czk, deviation_cost_czk, planning_run_id
from ems.audit_interval
where site_id = :id and interval_start >= :od and interval_start < :do
order by interval_start;
```
+ efektivní ceny: `ems.vw_site_effective_price` (stejné okno). Hledej sloty,
kde tok jde PROTI ceně (import za draho při nabité baterii, export při sell<0…).
## 2. Plán a jeho zdůvodnění
- Aktivní run pro slot: `audit_interval.planning_run_id``ems.planning_run`
(`solver_params`: `version`, `relax_chain`, `neg_sell_*`, `evening_push_ts`…)
a `ems.planning_interval` (setpointy, expected_cost).
- `ems.fn_plan_explain_bundle` + skill `.cursor/skills/ems-plan-explain`.
- v1 vs v2 shadow diff: `planning_run.solver_params->'comparison'`
(`diff.total_expected_cost_czk`, `slot_diffs` — kde se verze rozcházejí).
## 3. Replay lokálně (přesná rekonstrukce)
```bash
python3 scripts/harness/extract_fixtures.py --site-code <code> --day <YYYY-MM-DD> --tag triage_<duvod>
cd backend && python3 ../scripts/harness/solver_v2_eval.py # v1 (golden) vs v2 na fixture
```
Pozor: context = AKTUÁLNÍ konfigurace; pro historickou věrnost srovnej
`planning_run.solver_params.inputs` (battery parametry tehdy).
## 4. Optimum (kolik se nechalo na stole)
```bash
EMS_DB_DSN=… python3 scripts/harness/economics_report.py --site-code <code> --from <den> --to <den>
```
GAP = forecast error + neefektivita dispatche. Pro oddělení: porovnej plán
(forecast vstupy) vs oracle (skutečné PV/load) — velký rozdíl plán/oracle při
malém rozdílu plán/realita ⇒ chyba forecastu, ne dispatche.
## 5. Verdikt — vždy jedna z kategorií + číslo v Kč
- **forecast error** (PV/load se netrefil; plán byl na svá data racionální),
- **heuristika v1** (penalty/maska vynutila neekonomický tok — ukaž kterou:
vypni ji přes `penalty_audit.py --only NAZEV` na fixture dne),
- **tvrdé pravidlo** (block_export, arb floor, breaker, režim — správné chování),
- **chyba modelu v2** (jen pokud aktivní v2; ověř `solver_v2_eval.py` + unit testy),
- **exekuce** (plán dobrý, zařízení neposlechlo — `ems.modbus_command` journal,
skill ems-planner-bug-triage).
## Zásady
- Žádné závěry bez čísel ze SQL/harnessu; vždy uveď sloty a Kč.
- Nikdy neměnit plánovač bez golden gate (viz docs/refactor-clean-planner.md).
- Nálezy zapsat do docs/planning-changelog.md (formát: datum · problém · příčina · ověření).

View File

@@ -0,0 +1,279 @@
---
name: planner-battery-tuning
overview: Opravíme nesoulad mezi plánem a zápisem do Deye při nabíjení z FVE přebytku, doplníme SQL-first vstupy pro denní safety charge, aplikujeme je v LP jako soft penalty a uložíme debug snapshot každého běhu planneru.
todos:
- id: fix-deye-passive-charge
content: Opravit Deye PASSIVE překlad tak, aby plánované nabíjení z FVE přebytku nezapsalo reg108=0.
status: completed
- id: add-planner-debug-snapshot
content: Ukládat ke každému planning_run kompaktní debug JSON do solver_params se sekcemi inputs, masks, soc_bounds, objective_terms a chosen_slots.
status: pending
- id: prevent-charge-deferral
content: Doplnit near-term commitment / soft target před drahým sell oknem, aby rolling replan neodkládal nabíjení bez ekonomické náhrady.
status: pending
- id: add-daytime-safety-charge
content: Spočítat safety-charge vstupy v SQL, předat je do LP a aplikovat jako měkkou penalizaci deficitu proti noční energii.
status: pending
- id: add-regression-test
content: Přidat regresní testy pro PV surplus charge + současný net export a pro neodkládání nabíjení při receding horizon.
status: completed
- id: tune-small-site-terminal-soc
content: Po debug ověření upravit parametry BA81/KV1 cíleně; nezačínat slepým přepsáním `planner_terminal_soc_value_factor` na 0.9.
status: cancelled
- id: update-docs
content: Aktualizovat dokumentaci control/planning a ověřovací MCP dotazy.
status: completed
- id: verify
content: Spustit testy/validaci a sepsat očekávané MCP ověření po deployi.
status: completed
isProject: false
---
# Stabilizace plánovače baterie
## Cíl
Opravit tři související problémy:
- Plán někdy chce nabíjet baterii z PV přebytku, ale Deye dostane `reg108 = 0`, takže fyzicky nenabíjí.
- Rolling replan umí posouvat plánované nabíjení dál a dál, až levné PV okno uteče.
- Malé baterie BA81/KV1 potřebují robustní denní nabití pro noc, ale zároveň nesmí ztratit schopnost ekonomicky cyklovat a prodávat v opravdu drahých sell oknech.
## Datové zjištění
- `BA81` = site `3`, `KV1` = site `4`, `home-01` = site `2`.
- KV1 run `8101` pro slot 17:15 plánoval `battery_setpoint_w = 4737` W, `grid_setpoint_w = -13` W, `deye_physical_mode = PASSIVE`; `modbus_command` následně zapsal a ověřil Deye `register = 108`, `value_to_write = 0`. To je konkrétní bug v control exportu.
- BA81 historie rolling runů ukazuje posun prvního charge slotu s časem. To je částečně normální receding-horizon efekt, ale nesmí prodat levný PV přebytek, který je potřeba pro pozdější sell peak nebo noční baseload.
- `planner_terminal_soc_value_factor` není jediné řešení. BA81/KV1 mají `0.2`, home-01 má `0.9`; nezvyšovat BA81/KV1 plošně na `0.9`, protože to může vrátit starou neochotu malé baterie cyklovat.
## Architektonické rozhodnutí
- SQL-first zůstává: výpočet vstupů pro planner patří do SQL funkcí / view.
- Safety charge nesmí být hard `allow_charge` maska. SQL má spočítat vstupní hodnoty, LP je použije jako soft penalty v objective.
- Debug snapshot ukládat do existujícího `ems.planning_run.solver_params`. Samostatnou tabulku nezavádět v první iteraci.
- Hodnota energie v baterii není jedna konstanta: `battery_value = max(future_avoided_buy, future_sell_opportunity) - degradation`, plus samostatný měkký noční buffer.
## Implementace
### 1. Oprava Deye exportéru
Soubory:
- [`backend/services/control/inverter.py`](backend/services/control/inverter.py)
- [`backend/services/control/setpoints.py`](backend/services/control/setpoints.py)
Požadované chování:
- Pokud `ControlSetpoints.battery_w > 0`, Deye musí dostat nenulový nabíjecí proud podle `battery_w`, i když `grid_setpoint_w < 0`.
- V tomto scénáři zůstává `deye_physical_mode = PASSIVE`, pokud plán explicitně neurčí `CHARGE`. Nejde o grid-charge režim; jde o nabíjení z PV přebytku a současný export zbytku.
- `discharge_a` v tomto scénáři nastavit na `0` nebo jinak omezit tak, aby Deye současně nevybíjel baterii.
- Existující SELL a PRESERVE chování neměnit.
Konkrétní místo:
- V `write_inverter_setpoints()` je problém v PASSIVE větvi, která přes `_deye_zero_export_amps_for_passive()` vrací `charge_a = 0`, když `grid_w < 0` a `bat_w >= 0`.
- Přidej před tuto větev explicitní případ `bat_w > 0`: `charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)`, `discharge_a = 0`.
### 2. SQL vstupy pro daytime safety charge
Soubory:
- [`db/routines/R__063_fn_load_planning_slots_full.sql`](db/routines/R__063_fn_load_planning_slots_full.sql)
- případně nová repeatable funkce v [`db/routines`](db/routines)
Neimplementovat jako hard masku. Nezakazovat / nepovolovat sloty natvrdo jen kvůli safety charge.
Doplnit SQL výstupy, které Python LP použije:
- `night_baseload_target_wh`: kolik Wh je potřeba od večera do dalšího ranního PV okna.
- `night_baseload_buffer_wh`: bezpečnostní přirážka, např. procento z cíle.
- `safety_soc_target_wh`: doporučený SoC cíl pro slot.
- `future_avoided_buy_czk_kwh`: odhad ceny, kterou baterie ušetří, pokud energii necháme pro vlastní spotřebu.
- `future_sell_opportunity_czk_kwh`: nejlepší relevantní budoucí sell příležitost v horizontu.
- `is_daytime_pv_surplus_slot`: pomocný boolean pro debug a vážení cíle.
Preferovaný způsob:
- Rozšířit `ems.fn_load_planning_slots_full(...)`, protože už je hlavní zdroj slotových vstupů pro `_load_slots()`.
- Pokud by rozšíření funkce bylo příliš velké, vytvořit samostatnou `ems.fn_planning_safety_charge_inputs(site_id, from, to, current_soc_wh)` a joinovat podle `interval_start` v SQL/Pythonu.
Výpočet nočního okna:
- Praktická první verze: noc = od lokálního západu / večerního konce PV surplus do dalšího rána, zjednodušeně `20:00-06:00 Europe/Prague`.
- Přesnější verze později: od posledního dnešního slotu s významným PV forecastem do prvního zítřejšího slotu s významným PV forecastem.
- Pro první implementaci stačí konzervativní a čitelná definice, hlavně ji uložit do debug snapshotu.
### 3. Rozšíření Python datových tříd a načítání slotů
Soubor:
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
Upravit `PlanningSlot`:
- Přidat volitelná pole pro SQL safety vstupy:
- `safety_soc_target_wh: float | None`
- `night_baseload_target_wh: float | None`
- `night_baseload_buffer_wh: float | None`
- `future_avoided_buy_czk_kwh: float | None`
- `future_sell_opportunity_czk_kwh: float | None`
- `is_daytime_pv_surplus_slot: bool = False`
Upravit `_load_slots()`:
- Načíst nové sloupce ze SQL.
- Pokud SQL sloupce dočasně nejsou k dispozici, použít bezpečný fallback `None` / `False`, aby testy starších DB funkcí nespadly.
- Nepočítat noční baseload ad-hoc v Pythonu, pokud už SQL funkce hodnotu vrací.
### 4. LP objective: soft safety target
Soubor:
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
Přidat do `solve_dispatch()`:
- Pro každý slot `t` s `safety_soc_target_wh is not None` vytvořit spojitou proměnnou `safety_deficit_wh[t] >= 0`.
- Přidat omezení:
- `safety_deficit_wh[t] >= safety_soc_target_wh[t] - soc[t]`
- Přidat do objective penalizaci:
- `safety_deficit_wh[t] * safety_penalty_czk_per_wh[t]`
Výpočet penalty:
- `battery_value_czk_kwh = max(future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh) - degradation_cost_effective`
- `safety_penalty_czk_per_wh = max(0, battery_value_czk_kwh) / 1000`
- Přidat rozumný clamp, aby penalty nebyla extrémní kvůli vadné ceně.
Chování:
- Pokud je vysoký sell peak ekonomicky lepší než držet energii pro noc, LP smí target porušit a prodat.
- Pokud je budoucí nákup drahý, typicky KV1, deficit bude drahý a LP bude energii spíš držet pro vlastní spotřebu.
- Toto není hard constraint.
### 5. Near-term commitment proti deferralu
Soubory:
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
- DB čtení z `ems.planning_run` / `ems.planning_interval` přes SQL funkci nebo jednoduchý read model
Cíl:
- Rolling replan nesmí bez náhrady odsunout nejbližší plánované nabíjení z PV přebytku, pokud předchozí aktivní plán pro stejný nebo nejbližší slot chtěl nabíjet.
První jednoduchá implementace:
- Při rolling replanu načíst předchozí aktivní plán pro stejné `site_id`.
- Najít nejbližší 1-2 sloty od `replan_from`, kde předchozí plán měl:
- `battery_setpoint_w > 500`
- `pv_a_forecast_solver_w + pv_b_forecast_solver_w > load_baseline_w`
- ideálně `grid_setpoint_w <= 0`
- V novém LP pro odpovídající slot přidat soft proměnnou `charge_commitment_shortfall_w[t] >= previous_battery_charge_w - bc[t]`.
- Penalizace má být malá, ale nenulová: má zabránit bezdůvodnému odsunu, ne přebít skutečně lepší ekonomiku.
- Uložit do debug snapshotu, kdy commitment vznikl a kolik stál.
Neimplementovat jako hard constraint.
### 6. Debug snapshot do solver_params
Soubory:
- [`backend/services/planning_engine.py`](backend/services/planning_engine.py)
- [`db/routines/R__037_fn_planning_run_commit.sql`](db/routines/R__037_fn_planning_run_commit.sql)
Upravit `_save_planning_run()`:
- Rozšířit `run_meta` o `solver_params`.
- `solver_params` bude JSON serializovatelný dict.
Upravit `ems.fn_planning_run_commit(...)`:
- Při insertu do `ems.planning_run` uložit `solver_params = p_run_meta->'solver_params'`.
Minimální struktura JSON:
```json
{
"version": 1,
"inputs": {
"current_soc_wh": 0,
"operating_mode": "AUTO",
"battery": {
"usable_capacity_wh": 0,
"min_soc_wh": 0,
"reserve_soc_wh": 0,
"degradation_cost_czk_kwh": 0,
"planner_terminal_soc_value_factor": 0.2
}
},
"masks": [
{
"slot": "2026-05-04T15:45:00+00:00",
"allow_charge": true,
"allow_discharge_export": false
}
],
"soc_bounds": [
{
"slot": "2026-05-04T15:45:00+00:00",
"soc_min_wh": 0,
"arb_floor_wh": 0,
"soc_panel_min_wh": 0,
"safety_soc_target_wh": 0
}
],
"objective_terms": [
{
"slot": "2026-05-04T15:45:00+00:00",
"buy_price": 0,
"sell_price": 0,
"future_avoided_buy_czk_kwh": 0,
"future_sell_opportunity_czk_kwh": 0,
"battery_value_czk_kwh": 0,
"safety_deficit_penalty_czk_per_wh": 0,
"commitment_penalty_czk_per_w": 0
}
],
"chosen_slots": {
"charge_commitment": [],
"high_sell_windows": [],
"night_window": {
"start": "2026-05-04T18:00:00+00:00",
"end": "2026-05-05T04:00:00+00:00",
"target_wh": 0
}
}
}
```
### 7. Debug read model
Soubor:
- nová repeatable funkce v [`db/routines`](db/routines), např. `R__086_fn_planning_run_debug.sql`
Vytvořit `ems.fn_planning_run_debug(p_run_id int)`:
- Vrátí jeden `jsonb`.
- Obsahuje:
- metadata z `planning_run`,
- `solver_params`,
- intervaly z `planning_interval` pro daný run,
- krátký souhrn: první charge slot, první battery export slot, nejdražší sell sloty, největší safety deficit.
Použití přes MCP:
```sql
select ems.fn_planning_run_debug(8107);
```
### 8. Parametry
Nepřepisovat plošně BA81/KV1 na `planner_terminal_soc_value_factor = 0.9`.
Nové parametry preferovaně v `ems.asset_battery` přes novou migraci:
- `planner_daytime_charge_target_enabled boolean default true`
- `planner_night_baseload_buffer_percent numeric default 20`
- `planner_daytime_charge_price_quantile numeric default 0.70`
- `planner_charge_commitment_penalty_czk_kwh numeric default 0.20`
Pokud je rozsah příliš velký, první iterace může mít konzervativní konstanty v Pythonu, ale plánovaná cílová podoba je DB parametrizace.
### 9. Testy
Najít existující testovací styl v repu a přidat testy co nejblíže dotčeným modulům.
Povinné scénáře:
- Control exporter: `battery_w > 0`, `grid_setpoint_w < 0`, `deye_physical_mode = PASSIVE` vede na `reg108 > 0`, `reg109 = 0`.
- Control exporter: SELL režim se nezmění.
- Planner safety: malá baterie, PV surplus přes den, noční baseload, pozdější drahý sell slot. LP má nabíjet v rozumně levném PV slotu a neodsunout charge donekonečna.
- Planner economics: pokud `sell_now` převyšuje budoucí avoided buy plus degradaci, LP smí porušit safety target a prodat.
- Planner economics KV1-like: pokud budoucí buy je drahý a sell není dost vysoký, LP má držet energii pro vlastní spotřebu.
### 10. Dokumentace
Aktualizovat:
- [`docs/04-modules/control.md`](docs/04-modules/control.md)
- [`docs/04-modules/planning.md`](docs/04-modules/planning.md)
Dokumentace musí popsat:
- rozdíl mezi plánem, Deye fyzickým režimem a registry `108/109`,
- PV-surplus charging při současném exportu,
- `solver_params` debug snapshot a `fn_planning_run_debug`,
- rozdíl mezi hard maskami (`allow_charge`, `allow_discharge_export`) a soft LP penalizacemi,
- že `planner_terminal_soc_value_factor` není jediný mechanismus ochrany malé baterie.
## Ověření
- Spustit backend testy pro control a planner.
- Spustit Flyway validate lokálně.
- Přes MCP ověřit po nasazení:
- pro BA81/KV1 sloty s `battery_setpoint_w > 0` a `grid_setpoint_w < 0` má následný `modbus_command.register = 108` hodnotu > 0,
- `planning_run.solver_params` není `NULL` a obsahuje `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`,
- `select ems.fn_planning_run_debug(<run_id>)` vrací vysvětlitelný JSON,
- rolling replan neodkládá nabíjení z levného PV přebytku bez viditelného ekonomického důvodu v debug snapshotu.

View File

@@ -0,0 +1,97 @@
---
name: Unify PV correction source
status: draft
owner: cursor-agent
---
## Cíl
Uděláme **single source of truth** pro PV forecast používaný v plánování tak, aby:
- **solver** i **UI** četly *stejnou* PV řadu (žádné „dvě korekce dvěma cestami“),
- kanonický výpočet byl v **PostgreSQL**,
- výsledky byly auditovatelné (raw vs delta vs rolling-factor + decay).
## Zjištěný problém (dnes)
- Solver používá PV z DB + **multiplikativní rolling faktor** v Pythonu (`compute_correction_factor` + `apply_forecast_correction` v `backend/services/planning_engine.py`).
- UI (Planning tabulka) zobrazuje PV přes endpoint **delta-korekce** (`/forecast/pv-slots-corrected``ems.fn_forecast_pv_slots_range_corrected`), což může být jiné číslo než PV, se kterým solver počítal.
- Důsledek: v tabulce slotů nesedí výkonová bilance (UI ukáže např. 5.9 kW, ale plán implicitně pracuje s ~10.4 kW).
## Cílové chování (nová kanonická DB řada)
Kanonické PV pro plánování definujeme jako kombinaci obou korekcí:
1. **Delta-korekce (aditivní)** per PV array (odečíst `delta_profile[slot_of_day]`, clamp na 0)
2. Agregace do **PV-A / PV-B** podle `ems.asset_pv_array.controllable`
3. **Rolling faktor (multiplikativní)** z `ems.fn_pv_forecast_correction_factor(...)` aplikovaný na PV-A i PV-B
4. **Decay (lineární útlum)** faktoru podle offsetu slotu od `now` (stejná logika jako dnes v `apply_forecast_correction`)
Výstup této kanonické řady se musí propsat do:
- `ems.fn_load_planning_slots_full` (vstup pro solver),
- `ems.fn_plan_current_bundle` (výstup pro UI),
- a do uložených sloupců `planning_interval.pv_*_forecast_solver_w` (audit).
## Návrh DB API (kanonická funkce)
Přidat novou repeatable rutinu, např.:
- `db/routines/R__0xx_fn_forecast_pv_slots_range_canonical_ab.sql`
Funkce vrátí JSON pole slotů pro `[from, to)` s minimálně:
- `interval_start`
- `pv_a_forecast_raw_w`, `pv_b_forecast_raw_w`
- `pv_a_forecast_delta_w`, `pv_b_forecast_delta_w` (po delta-korekci)
- `rolling_factor` (globální faktor) + `rolling_effective_factor` (po decay pro slot)
- `pv_a_forecast_canonical_w`, `pv_b_forecast_canonical_w` (delta × rolling_effective_factor)
Poznámky:
- Delta profil už dnes existuje (`ems.fn_pv_forecast_delta_profile`) a `fn_forecast_pv_slots_range_corrected` už umí per-array delty; tu logiku zrecyklujeme.
- Rolling faktor už dnes existuje v DB (`ems.fn_pv_forecast_correction_factor`), jen se dnes aplikuje v Pythonu.
- Decay parametrizovat (např. `p_decay_slots int default 16`, `p_min_clamp numeric`, `p_max_clamp numeric`, `p_window_h numeric`).
## Změny solveru (Python)
V `backend/services/planning_engine.py`:
- Přepnout loader PV slotů na kanonickou DB řadu (A/B corrected).
- **Odstranit** aplikaci PV korekce v Pythonu (nebo ji dočasně nechat za feature flagem jen jako fallback při chybě DB funkce).
- Uložit do `planning_run` diagnostiku (např. `forecast_correction_factor` nahradit/rozšířit o `pv_forecast_method = 'canonical_db_delta+rolling'` + `rolling_factor`).
## Změny DB pro plánovací sloty a current bundle
- `db/routines/R__063_fn_load_planning_slots_full.sql`:
- zdroj PV A/B musí být `pv_*_forecast_canonical_w` z nové funkce.
- zachovat raw/solver sloupce pro audit a UI.
- `ems.fn_plan_current_bundle` (repeatable rutina ve `db/routines/`, dohledat a upravit):
- pro intervaly z `planning_interval` vracet explicitně:
- `pv_a_forecast_solver_w`, `pv_b_forecast_solver_w`, a `pv_forecast_total_w = pva+pvb` (aby UI nemuselo „domýšlet“ přes jiné endpointy),
- pro sloty za horizontem (forecast extension) vracet `pv_forecast_total_w` jako **kanonický součet** (canonical A+B) z nové funkce.
## Změny UI
V `frontend/src/pages/Planning.tsx`:
- Pro tabulku slotů a graf použít **jen** PV z `/sites/{id}/plan/current`:
- pro plánované sloty: `pv_a_forecast_solver_w + pv_b_forecast_solver_w` (ne `pv-slots-corrected`),
- pro forecast-only sloty: `pv_forecast_total_w` (které už bude kanonické z DB).
- Endpoint `/forecast/pv-slots-corrected` ponechat pro stránku forecastu a diagnostiku, ale **ne** jako zdroj pro Planning tabulku.
## Ověření
- Pro konkrétní slot (např. `home-01`, `10:15` Prague) musí sedět:
- UI PV (z `/plan/current`) == PV v solver vstupu == uložené `planning_interval.pv_*_forecast_solver_w`.
- Výkonová bilance v tabulce slotů: `PV - load - EV - HP = battery + export(+/- import)` bez „magické energie“.
- Doplnit regresní test: UI zobrazuje stejné PV jako `planning_interval` (alespoň na DTO úrovni / snapshot).
## Dokumentace
Aktualizovat:
- `docs/04-modules/forecast.md` (kde vzniká kanonické PV: delta + rolling factor + decay),
- `docs/04-modules/planning.md` (solver čte kanonický PV z DB; UI používá stejné sloupce z `/plan/current`),
- případně krátká poznámka do `docs/02-architecture.md` k „read-model = single point of truth“ pro plán.

View File

@@ -0,0 +1,37 @@
---
description: EMS plánování — doptat se, ekonomický zisk, bez mikrocyklů
alwaysApply: true
---
# EMS agent — plánování a ekonomika
## Doptat se
- Pokud zadání **není exaktní** (lokalita, časové okno, cílové SoC, co je bug vs. záměr), **vždy se doptat** před větší změnou kódu/SQL.
- Nehádat záměr uživatele (příklad: večerní export za ~3 Kč při buy ~5 Kč může být **správně** pro vyprázdnění před neg dnem).
## Ekonomický cíl
- Návrhy a implementace směřuj k **provoznímu zisku** (arbitráž, FVE, neg-sell okno, večerní špičky).
- **Výjimka:** neoptimalizovat **mikrocyklování** (souběžný import + export / zbytečné cykly v jednom slotu).
## Dvě podlahy SoC (home-01, sloupce v `ems.asset_battery`)
| Sloupec | % | Role |
|--------|---|------|
| **`reserve_soc_percent`** | 20 | **Export / strategie:** večerní push, ranní peak před `sell<0`, kotvy `neg_evening_reserve_soc_anchors` — cíl „ráno ~20 % před FVE“. Pod tímto plánovač **neplánuje zbytečný export** (V027 komentář). |
| **`min_soc_percent`** | 10 | **Spotřeba domu (Deye PASSIVE):** LP a exekuce smí vybíjet baterii pro load až sem — rezerva na **nenadálou spotřebu**, aby se nekupovalo ze sítě za draho. |
| **`planner_discharge_floor_percent`** | 5 | Jen **LP relaxace** pod `min_soc` (ne provozní cíl). |
**Nesplést:** vybít kvůli **prodeji** → podlaha **reserve**; vybít kvůli **domu v noci** → může jít k **min_soc**.
## Neg okno vs. `buy < 0`
- **`sell < 0`:** export zakázán; **headroom** = místo v baterii pro FVE v okně (v44 `neg_day_no_grid_before_neg_sell`, prep rampa). **Ne** totéž co „vyčerpat před sell<0“ u **`buy < 0`**.
- **`buy < 0`:** levné **nabíjení ze sítě** (priorita importu), ne strategie „vyprázdnit před neg výkupen“.
Před implementací změny exportních podlah: **zeptat se**, jestli cíl je „k 20 % před svítáním“ vs. „ještě níž pro headroom v sell<0“.
## Komunikace
- Bez ritualního „máš pravdu“; konkrétní fakta z DB/MCP, co změnit, jak ověřit.

View File

@@ -73,6 +73,7 @@ Krátce a v pořadí:
- Záporná **prodejní** cena → export do sítě v LP **neekonomický** / u části instalací **tvrdě 0**; přebytek → nabíjení / curtailment **A** / GEN cutoff (viz `solve_dispatch` v `backend/services/planning_engine.py`).
- **Pole B** je v modelu **nekontrolovatelné** — nelze ho `pv_a_curtailed` omezit.
- **Zelený bonus** není v účelové funkci LP; počítá se v auditu (`fn_green_bonus_revenue`) — viz `docs/04-modules/planning.md`.
- **~60 % SoC ve slunci (BA81/KV1)** nebo **ranní export před sell&lt;0 (home-01):** často **v58** (`bc_pv=0` při `sell > min+0,20`) nebo **v33 pre-neg cushion** — plánovaná náhrada: `docs/04-modules/planning-charge-slot-budget.md` (zatím ne v produkci).
4. **Mezery modelu** (upozornit jednou větu, když je to relevantní):
- LP používá horní strop **`max_charge_power_w`** bez závislosti na SoC → u vysokého SoC může reálný proud být nižší než plán.
@@ -86,6 +87,10 @@ Krátce a v pořadí:
→ [reference.md](reference.md)
## Související skill
- **Bug / incident plánovače** (422 Infeasible, degradovaný relaxed solve, večerní export, multi-site): [ems-planner-bug-triage](../ems-planner-bug-triage/SKILL.md) — triáž a návrh fix větve; tento skill zůstává pro vysvětlení slotů.
## Anti-patterns
- **Hromadná analýza** (`fn_plan_explain_bundle`, `planning_interval` pro více `site_id`) jen proto, že uživatel **neřekl kterou** lokalitu — vždy se **nejprve** zeptat.

View File

@@ -0,0 +1,106 @@
---
name: ems-planner-bug-triage
description: >-
Triages EMS planner bugs from live Postgres (MCP): degraded Infeasible retry chain,
evening export vs battery hold, neg-buy/neg-sell days, fixed-tariff sites (BA81/KV1),
failed replan vs stale active plan. Use when the user reports planner errors, wrong
evening export, battery held while buy ~5 Kč/kWh, relaxed_neg_prep_window,
evening_push_hard_suppressed, or multi-site planner comparison. Complements
ems-plan-explain — use this skill for incident/bug classification and fix-branch hints.
---
# EMS — triáž bugů plánovače
## Kdy použít (vs. ems-plan-explain)
| Situace | Skill |
|---------|--------|
| „Proč v tom slotu nabíjí / neexportuje?“ | [ems-plan-explain](../ems-plan-explain/SKILL.md) |
| „Plánovač blbne / neprodává večer / 422 Infeasible / BA81 vs home-01“ | **tento skill** |
| Degradovaný plán (relaxed solve) ale run `active` | **tento skill** |
| Porovnání více lokalit uživatel **explicitně** jmenoval | **tento skill** (jinak jedna site) |
Typické spouštěče: *neprodává večer*, *drží baterku @ 5 Kč*, *nepřeplánovalo po OTE*, *Solver: Infeasible*, *evening_push prázdný*, *sell&lt;0 a výroba do site*.
## Tvrdá pravidla
1. **MCP first** — server `user-postgres-ems`, nástroj `query`, jen `SELECT`. Po chybě: přesný text + VPN/MCP zapnutý.
2. **Selhání plánu není v `planning_run`** — status je jen `active` / `superseded` / `comparison` / `draft`. API **422** a scheduler exception = logy backendu; aktivní run může být **starý** nebo **degradovaný**.
3. Rozliš: **plán v DB** vs **exekuce**`site_operating_mode != AUTO` → rolling replan se **přeskakuje** (ne nutně solver bug).
4. **Dvě podlahy SoC:** export / strategie → **`reserve_soc_percent`** (~20 %); dům v noci (Deye PASSIVE) → může jít k **`min_soc_percent`** (~10 %). Před návrhem večerního vývoje se u exportu domluv **`reserve`**, ne `min_soc`, pokud uživatel neřekne jinak.
5. **`neg sell``buy < 0`:** vyprázdnit před **`sell < 0`** (headroom FVE) není totéž co strategie před **`buy < 0`** (levný import).
## Checklist triáže
```
- [ ] site_id (kód / id / výběr ze seznamu — viz ems-plan-explain reference §0)
- [ ] site_operating_mode + poslední řádky site_operating_mode_log
- [ ] active planning_run: id, created_at, run_type, triggered_by, soc_at_replan_wh
- [ ] solver_params.inputs: relaxed_*, evening_push_hard_suppressed, neg_evening_*,
pre_neg_pv_export_forecast_ok, charge_acquisition_buy_czk_kwh
- [ ] Večerní okno: planning_interval (battery/grid setpoint, soc target, ceny)
- [ ] Zítra: neg sloty ve vw_site_effective_price; snap neg_sell_day_pv_usable_wh
- [ ] Klasifikace bug typu AE (reference.md §1)
- [ ] Návrh fix větve + ověření po fixu
```
## Rozhodovací strom
```
Replan 422 / žádný nový run po OTE?
├─ mode != AUTO → provozní (MANUAL/SELF_SUSTAIN), plán zastaralý
├─ poslední run any_relaxed + evening_push_hard_suppressed
│ └─ BUG typ A/B: degradovaný solve — Branch 1 + 2
└─ žádný řádek v planning_run → backend log „Infeasible“ (Branch 1)
Večer neprodává, drží vysoké SoC?
├─ evening_push_hard_suppressed = true → tvrdý push vypnutý (Branch 2)
├─ grid import @ drahý buy + vysoké SoC → terminal SoC + pos_sell_pre_neg_buy (Branch 2/5)
├─ zítra buy<0 + vysoká FVE, pre_neg ok = false
│ └─ měl jít večer k reserve, ne držet ~80 % (Branch 2)
└─ fixed tarif + evening_push_ts = [] → v58 / charge-slot-budget (Branch 3)
BA81: sell<0, výroba „do site“ / export?
└─ deye_gen_microinverter_cutoff + pole B — exekuce GEN (Branch 4)
```
## Retry řetězec (solve_dispatch)
Při Infeasible solver postupně zapíná (viz `planning_engine.py` ~4216):
1. `relaxed_expensive_import`
2. `relaxed_neg_buy_charge`
3. `relaxed_neg_prep_window`**vypne** neg-evening push, kotvy, prep hold; **`evening_push_hard_suppressed = true`**
4. `neg_sell_phases_fallback` (prep_soc = 100 %)
**Symptom degradace:** run `active`, ale ve špičce **import ze sítě** místo exportu baterie; `neg_evening_push_ts` prázdné; plán drží SoC nad očekávání před neg dnem.
## Výstup pro uživatele (šablona)
1. **Fakta z DB** — run id, čas, mode, 35 klíčových flags, 23 večerní sloty (W, SoC %, buy/sell).
2. **Root cause** — jedna věta: degradovaný retry / konfigurace site / režim / exekuce.
3. **Bug typ** — AE z [reference.md](reference.md).
4. **Doporučená větev opravy** — Branch 15 + soubor v repu.
5. **Ověření** — MCP dotaz nebo `pytest backend/tests/test_planning_dispatch_milp.py -k "…"`.
## Kód a dokumentace
| Téma | Soubor |
|------|--------|
| LP + retry | `backend/services/planning_engine.py``solve_dispatch` |
| `evening_push_hard_suppressed` | ~2810 |
| `pos_sell_pre_neg_buy``ge=0` | ~3929 |
| SQL masky | `db/routines/R__063_fn_load_planning_slots_full.sql` |
| Charge-slot budget (plán) | `docs/04-modules/planning-charge-slot-budget.md` |
| Changelog v55v57 | `docs/planning-changelog.md` |
| Bisect fixture | `scripts/diagnose_home01_infeasible.py` |
## SQL a archetypy site
→ [reference.md](reference.md)
## Anti-patterns
- Diagnostikovat Infeasible **bez** `solver_params.inputs` z posledního úspěšného runu — může být právě ten degradovaný.
- Zaměnit „plán neexportuje“ s „exporter neběží“ — v MANUAL/SELF_SUSTAIN se plán nemusí aktualizovat.
- U fixního tarifu očekávat stejné `evening_push_ts` jako u home-01 — BA81/KV1 mají jinou větev (viz reference §3).

View File

@@ -0,0 +1,202 @@
# EMS planner bug triage — reference (MCP)
Všechno jen **read-only** `SELECT`. Server: **`user-postgres-ems`**, nástroj **`query`**.
Seznam lokalit a `fn_plan_explain_bundle` → [ems-plan-explain/reference.md](../ems-plan-explain/reference.md).
---
## 1) Bug typy (klasifikace)
| Typ | Popis | Typické flags / signály | Fix větev |
|-----|--------|-------------------------|-----------|
| **A** | Degradovaný solve — Infeasible retry krok 3+ | `any_relaxed_solve`, `relaxed_neg_prep_window`, `evening_push_hard_suppressed` | Branch 1: granulární relaxace + failed-run journal |
| **B** | Večerní export chybí před neg dnem | Vysoké SoC ve špičce, `grid_setpoint_w > 0` @ ~5 Kč buy, `neg_evening_push_ts` prázdné, zítra `buy<0` / vysoká FVE | Branch 2: neg-večer k `reserve_soc` |
| **C** | Fixní tarif — špatné nabíjení / žádný večerní push | `evening_push_ts: []`, SoC ~6080 % ve slunci, v58 `bc_pv=0` | Branch 3: charge-slot-budget |
| **D** | Provoz / exekuce, ne LP | `mode != AUTO`, starý `active` run, ruční MANUAL | Návrat do AUTO, ruční replan po fixu |
| **E** | GEN port / pole B při sell&lt;0 | BA81 + `deye_gen_microinverter_cutoff`, audit export v sell&lt;0 | Branch 4: cutoff exekuce |
---
## 2) MCP — aktivní run + relax flags
Nahraď `$site_id` (např. 2 = home-01):
```sql
select pr.id,
pr.created_at,
pr.run_type,
pr.triggered_by,
pr.soc_at_replan_wh,
pr.solver_params->'inputs'->>'any_relaxed_solve' as relaxed,
pr.solver_params->'inputs'->>'relaxed_expensive_import' as r_exp_import,
pr.solver_params->'inputs'->>'relaxed_neg_buy_charge' as r_neg_buy,
pr.solver_params->'inputs'->>'relaxed_neg_prep_window' as r_neg_prep,
pr.solver_params->'inputs'->>'neg_sell_phases_fallback' as r_phases_off,
pr.solver_params->'inputs'->>'evening_push_hard_suppressed' as push_suppressed,
pr.solver_params->'inputs'->>'pre_neg_pv_export_forecast_ok' as pre_neg_ok,
pr.solver_params->'inputs'->>'neg_evening_push_ts' as neg_eve_push,
pr.solver_params->'inputs'->>'evening_push_ts' as evening_push,
pr.solver_params->'inputs'->>'charge_acquisition_buy_czk_kwh' as acq,
pr.solver_params->'inputs'->>'neg_sell_day_pv_usable_wh' as neg_pv_wh,
pr.solver_params->>'planner_build_tag' as tag
from ems.planning_run pr
where pr.site_id = $site_id
and pr.status = 'active'
order by pr.created_at desc
limit 1;
```
Poslední běhy (včetně comparison) za 48 h:
```sql
select pr.id, pr.status, pr.run_type, pr.created_at,
pr.solver_params->'inputs'->>'relaxed_neg_prep_window' as r3,
pr.solver_params->'inputs'->>'evening_push_hard_suppressed' as push_sup
from ems.planning_run pr
where pr.site_id = $site_id
and pr.created_at >= now() - interval '48 hours'
order by pr.created_at desc
limit 20;
```
---
## 3) Site archetypy (orientace)
| | **home-01** | **BA81** | **KV1** |
|---|-------------|----------|---------|
| Buy | spot | fixed ~2,55 NT | fixed 5,25 |
| `block_export_on_negative_sell` | false | false | **true** |
| Neg sell fáze | 80 % prep | default | **100 % (off)** |
| `deye_gen_microinverter_cutoff` | false | **true** | false |
| `planner_terminal_soc_value_factor` | **0,9** | 0,2 | 0,2 |
| Typický večerní bug | A + B (relaxed + drží SoC) | C (no evening_push) | občas A, jinak OK |
Konfigurace z DB:
```sql
select s.code,
smc.purchase_pricing_mode,
sgc.block_export_on_negative_sell,
ai.deye_gen_microinverter_cutoff_enabled,
ab.reserve_soc_percent,
ab.min_soc_percent,
ab.planner_neg_sell_prep_soc_percent,
ab.planner_terminal_soc_value_factor
from ems.site s
left join ems.site_market_config smc
on smc.site_id = s.id and smc.valid_to is null
left join ems.site_grid_connection sgc on sgc.site_id = s.id
left join ems.asset_inverter ai
on ai.site_id = s.id and ai.code = 'deye-main'
left join ems.asset_battery ab
on ab.site_id = s.id and ab.code = 'bat-main'
where s.code in ('home-01', 'BA81', 'KV1');
```
---
## 4) Provozní režim a log
```sql
select mode_code from ems.site_operating_mode where site_id = $site_id;
select activated_at at time zone 'Europe/Prague' as ts_prague,
mode_code,
activated_by
from ems.site_operating_mode_log
where site_id = $site_id
order by activated_at desc
limit 8;
```
Rolling replan běží jen v **AUTO**.
---
## 5) Večerní okno — planning_interval
```sql
select pi.interval_start at time zone 'Europe/Prague' as slot_prague,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
pi.battery_soc_target_pct,
pi.effective_buy_price,
pi.effective_sell_price
from ems.planning_interval pi
where pi.run_id = (
select id from ems.planning_run
where site_id = $site_id and status = 'active'
order by created_at desc limit 1
)
and pi.interval_start >= (current_timestamp at time zone 'Europe/Prague')::date
+ interval '17 hours'
and pi.interval_start < (current_timestamp at time zone 'Europe/Prague')::date
+ interval '1 day' + interval '6 hours'
order by pi.interval_start;
```
**Červená vlajka:** `battery_setpoint_w = 0` a `grid_setpoint_w > 0` při sell ~3 Kč a SoC &gt; reserve — import místo exportu baterie.
---
## 6) Zítřejší neg ceny
```sql
select interval_start at time zone 'Europe/Prague' as slot_prague,
effective_buy_price_czk_kwh as buy,
effective_sell_price_czk_kwh as sell
from ems.vw_site_effective_price
where site_id = $site_id
and interval_start >= (current_timestamp at time zone 'Europe/Prague')::date
+ interval '1 day'
and interval_start < (current_timestamp at time zone 'Europe/Prague')::date
+ interval '2 days'
and (effective_buy_price_czk_kwh < 0 or effective_sell_price_czk_kwh < 0)
order by interval_start;
```
---
## 7) Slovník solver_params.inputs (výběr)
| Klíč | Význam |
|------|--------|
| `evening_push_hard_suppressed` | true = **bez** tvrdého `ge_bat` push (typicky retry krok 3) |
| `relaxed_neg_prep_window` | Vypnuty neg-evening kotvy, prep hold, neg evening push |
| `pre_neg_pv_export_forecast_ok` | Cushion FVE v sell&lt;0 okně — false → nemá exportovat ranní PV před neg |
| `neg_evening_push_ts` | Sloty D1 večer pro vývoj před neg oknem |
| `charge_acquisition_buy_czk_kwh` | Účinná cena energie v baterii pro arbitráž exportu |
| `neg_sell_day_pv_usable_wh` | Forecast Wh do baterie v sell&lt;0 okně zítřka |
| `morning_pre_neg_export_hard` | Tvrdý ranní export před sell&lt;0 |
Plný snap: `select ems.fn_planning_run_debug(<run_id>);`
---
## 8) Fix větve (implementační plán v repu)
| Branch | Obsah | Priorita |
|--------|--------|----------|
| **1** | Failed-run journal, bisect Infeasible, granulární relaxace | P0 |
| **2** | home-01 neg-večer → `reserve_soc`, oddělit push od prep relax | P0 |
| **3** | charge-slot-budget v R__063, BA81/KV1 večerní export | P1 |
| **4** | BA81 GEN cutoff audit | P1 |
| **5** | Dynamický terminal SoC při future neg buy | P2 |
Detail: plán v `.cursor/plans/` nebo `docs/planning-changelog.md` + `docs/04-modules/planning-charge-slot-budget.md`.
---
## 9) Ověření po fixu
```bash
pytest backend/tests/test_planning_dispatch_milp.py -k "evening or NegSell or Infeasible"
```
```bash
python scripts/diagnose_home01_infeasible.py
```
MCP po deployi — nový active run **bez** `relaxed_neg_prep_window` nebo s `evening_push_hard_suppressed: false` a večerní sloty s `grid_setpoint_w < 0`.

View File

@@ -46,3 +46,5 @@ TELEMETRY_POLL_INTERVAL_SEC=60
PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ
PLANNING_CHEAP_PRICE_THRESHOLD=0.85
PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15
PLANNING_ENGINE_VERSION=v1 # v1 = původní planner, v2 = nová policy větev
PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params

View File

@@ -11,6 +11,7 @@ on:
push:
branches:
- main
- dev
- feature/**
pull_request:
workflow_dispatch:
@@ -78,7 +79,15 @@ jobs:
ls -ld /opt/ems-deploy
- name: Run deploy script
run: bash /opt/ems-deploy/deploy.sh
# Nejdřív aktualizovat checkout + ROOT kopii skriptu z repa (jinak by
# opravy deploy.sh nikdy nedoputovaly na server — skript se spouští
# z /opt/ems-deploy, ne z checkoutu). deploy.sh pak fetch/reset zopakuje
# idempotentně.
run: |
git -c safe.directory=/opt/ems-deploy/app -C /opt/ems-deploy/app fetch origin
git -c safe.directory=/opt/ems-deploy/app -C /opt/ems-deploy/app reset --hard origin/main
install -m 0755 /opt/ems-deploy/app/deploy/deploy.sh /opt/ems-deploy/deploy.sh
bash /opt/ems-deploy/deploy.sh
# Alternativa: runner v Dockeru bez přístupu k hostu — odkomentovat a upravit SERVER + secrets.
# deploy-ssh:

2
.gitignore vendored
View File

@@ -13,3 +13,5 @@ dist/
*.tsbuildinfo
frontend/vendor/
frontend/scripts/.native-tmp/
.claude/settings.local.json
.claude/worktrees/

View File

@@ -49,6 +49,8 @@ Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řá
| `db/routines/` | Repeatable SQL: funkce `ems.fn_*` |
| `db/views/` | Repeatable SQL: view `ems.vw_*` |
| `backend/services/` | Python služby (v repozitáři zatím hlavně plánování) |
| `backend/services/planning/` | Moduly plánovače: `constants` (vč. všech ekonomických penalt), `types`, `forecast`, `db_io`, `heuristics`; `planning_engine.py` = solver + orchestrace + fasáda (re-export, importy beze změny) |
| `backend/tests/golden/` + `scripts/harness/` | Ekonomický regresní harness: golden replay gate (`test_golden_replay.py`), `extract_fixtures.py`, `economics_report.py`, `penalty_audit.py` — viz `scripts/harness/README.md`; **při změně plánovače musí projít golden gate** |
---
@@ -68,7 +70,7 @@ Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řá
7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` nesmí „nekonečný“ import).
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků dle docs).
8. **PuLP + HiGHS** pro dispatch; žádný návrat k greedy `fn_plan_day` jako primárnímu řešení (SQL wrapper může zůstat pro uložení výsledků dle docs). **Ekonomika slotů:** masky + guardy v `solve_dispatch` — viz `docs/04-modules/planning.md`. **Arbitráž baterie:** neúčtovat `buy[t]`/`sell[t]` ve stejném 15min slotu jako nákup/prodej téže kWh; `min(buy)` horizontu ≠ cena nabití (home-01 nabíjí hodiny, ne jednu čtvrthodinu). Povinné: `docs/04-modules/planning-arbitrage-accounting.md`.
9. **Zelený bonus je na `asset_pv_array`** (sloupce `green_bonus_*`), **nikdy** v `site_market_config`. Výpočet přes `fn_green_bonus_revenue()`. Bonus se nepočítá v solveru pouze v audit_filler (`fn_fill_audit_interval`).
@@ -104,13 +106,15 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`; **bez EMA „ocasu“** přepočítáš smaž+hromadný update přes **`ems.fn_rebuild_consumption_baseline_stats(site_id, lookback)`** (`site_id NULL` → všechny lokality). **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`.
16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `(průměr buy v prvních 24 h slotů × planner_terminal_soc_value_factor / 1000) × soc[T1]` (Kč; SoC v Wh), kde **`planner_terminal_soc_value_factor`** je **`ems.asset_battery.planner_terminal_soc_value_factor`** načtené přes **`ems.fn_planning_site_context`** (žádný skrytý faktor v Pythonu). `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky a budoucí rozšíření; detail historie: `docs/04-modules/planning-extended-horizon.md`.
16. **Dynamický horizont plánování (jen OTE):** konec okna z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (`min` posledního OTE konce a `start + 36 h`, NULL pokud je známé OTE kratší než **1 h** od startu rolling se přeskočí; denní plán při NULL použije **1 h** fallback v Pythonu). Strop a práh měnit v SQL (defaultní argumenty funkce / repeatable migrace), ne přes env. Solver používá **výhradně** sloty s efektivní cenou z `vw_site_effective_price` (žádná predikce v LP). Účelová funkce má **terminal SoC shadow price**: `(průměr buy v prvních 24 h slotů × planner_terminal_soc_value_factor / 1000) × soc[T1]` (Kč; SoC v Wh), kde **`planner_terminal_soc_value_factor`** je **`ems.asset_battery.planner_terminal_soc_value_factor`** načtené přes **`ems.fn_planning_site_context`** (žádný skrytý faktor v Pythonu). **Fázované SoC v okně `sell < 0` (v32):** `planner_neg_sell_prep_soc_percent`, `planner_neg_sell_full_soc_tail_slots`, `planner_neg_sell_vent_min_sell_czk_kwh` na **`asset_battery`**; curtail A → reg 340, plná baterie = solar sell off bez zápisu 340. `market_price_stats` / `fn_get_predicted_price` zůstávají pro statistiky; detail: `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`.
17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord``fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **6264** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`.
18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` (lokalita se zeleným bonusem na PV poli) **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0`; hodnota z `pv_a_forecast_solver_w` / `pv_a_curtailed_w` (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 6264**, bloky TOU **12** vs **36**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **`export_mode=PV_SURPLUS`** → reg **108 sleduje charge intent plánu** (fix 2026-06-16): `bat_w>0`**108=max** (baterka nabere kolik fyzicky zvládne, přebytek **nad nabíjecí rychlost** do sítě — případ „výroba > rychlost baterky", BA81); SoC u maxima (`>= max_soc BATTERY_CALIB_TOPOFF_MARGIN_PCT`) + přebytek → **108=max** (BMS kalibrace na 100 %); jen `bat_w<=0` daleko od maxima → **108=0** (prodej PV, drž baterku). **109=max**, **142**=`deye_zero_export_mode` (ne selling first); jinak **108/109** dle `deye_battery_charge_discharge_amps` / `_deye_zero_export_amps_for_passive` (import bez vybíjení → **109=0**); **TOU SOC** (reg 166+): **PASSIVE** = **`min_soc_percent`**, **CHARGE** = **`max_soc_percent`** (clamp 10100 z DB), **SELL** = **`reserve_soc_percent`** (`_deye_passive_tou_battery_soc_pct`, `_deye_tou_params`). **SELL:** reg **108** EMS **nezapisuje** (selling first = **142**), **109**=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **Reg 340** (*max solar power*, W): jen pokud `ems.fn_site_has_active_green_bonus_pv(site_id)` **a** `ems.fn_inverter_pv_a_max_w(inverter_id) > 0` (strop z `deye_reg340_max_solar_w`, typ. 32k home-01 / 65k jinde, ne součet Wp; min `deye_reg340_min_solar_w`, home-01 400); hodnota z plánu / curtailu (AUTO). **Není** v `DEYE_CRITICAL_REGS_SELF_SUSTAIN` — verify mismatch nečeká přepnutí do SELF_SUSTAIN. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 6264**, bloky TOU **12** vs **36**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
19. **Baterie export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 1112 %, migrace V029 + komentář sloupce).
19. **HARD LIMIT exportu na fakturačním elektroměru — NIKDY nepřekročit.** Překročení rezervovaného exportního výkonu (home-01: 13.5 kW) byť o desetiny kW = smluvní pokuta v řádu desítek tisíc Kč za kW. Jediný bezpečný invariant: **reg 143 (limit na svorkách střídače) <= max_export_power_w (limit ulice) VŽDY** — v nejhorším případě (spotřeba mezi střídačem a CT odpadne) je ulice rovna svorkám. **ZAKÁZÁNO** jakékoli feed-forward navyšování terminálového limitu o měřenou spotřebu (výpadek spotřeby = přestřelení ulice). Vyšší vytěžení smí přinést jedině interní regulace střídače proti CT (firmware smyčka), nikdy náš software s 1min telemetrií a 15min ticky.
20. **Baterie export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 1112 %, migrace V029 + komentář sloupce).
---
@@ -159,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_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_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`.
---
@@ -201,7 +205,10 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
| Modbus journal, verifikace, Discord | `docs/04-modules/modbus-command-journal.md` |
| Deye registry (FC 0x10, 108/109/141/142/178/143/145/340) | `docs/04-modules/modbus-registers.md` |
| Export setpointů, Loxone HTTP | `docs/04-modules/control.md`, `docs/loxone-integration.md` |
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `planning_engine.py` |
| LP solver, rolling replan, korekce FVE, dynamický OTE horizont | `docs/04-modules/planning.md`, `docs/04-modules/planning-extended-horizon.md`, `docs/planning-changelog.md`, `planning_engine.py` |
| Rozpočet nabíjecích slotů (Wh × ceny × forecast; plánováno) | `docs/04-modules/planning-charge-slot-budget.md` — náhrada v58 + pre-neg cushion |
| Záporný výkup, bod T, termika, bazén (home-01 strategie) | `docs/04-modules/planning-neg-sell-strategy.md` |
| Arbitráž baterie (mezi sloty ≠ buy/sell v jednom 15min) | `docs/04-modules/planning-arbitrage-accounting.md` |
| Provozní režimy AUTO / SELF_SUSTAIN / … | `docs/04-modules/operating-modes.md`, `db/migration/V004__operating_modes.sql`, `db/routines/R__044_fn_set_mode.sql` |
| EV, session, deadline charging | `docs/04-modules/ev-charging.md`, `db/migration/V006__vehicles.sql` |
| Curtailment A, zelený bonus B | `db/migration/V005__planning_curtailment.sql` |
@@ -213,15 +220,23 @@ Specifikace z `docs/02-architecture.md`, modulových docs a komentářů v `plan
| Reset DB / restore z dumpu (Docker volume, Timescale) | `docs/database-reset-and-restore.md`, `scripts/import_ems_db.sh` |
| Nespecifikované chování | `docs/06-open-questions.md` (přidat otázku, neimpl. naslepo) |
| **MCP read-only SQL na EMS DB** | **`docs/07-mcp-postgres-ems.md`** — server ID **`user-postgres-ems`**, nástroj **`query`**, `{"sql":"…"}`. Pravidlo **`.cursor/rules/mcp-postgres-ems.mdc`**. |
| **Refaktor „Čistý plánovač“ (fáze, stav, nasazení v2)** | **`docs/refactor-clean-planner.md`**; verze enginu v1/v2 + env flagy: `docs/04-modules/planning.md` (sekce Verze enginu); changelog 2026-06-11 |
| **Čisté jádro plánovače v2** | `backend/services/planning/solver_v2.py`, testy `backend/tests/test_solver_v2.py`, eval `scripts/harness/solver_v2_eval.py` |
| **Delta-triáž neekonomického chování (agent skill)** | **`.claude/skills/ems-delta-triage/`** — realita vs plán vs shadow peer vs oracle, verdikt s Kč |
| **Vysvětlení plánu (agent skill)** | **`.cursor/skills/ems-plan-explain/`** — `fn_plan_explain_bundle`, sloty, proč nabíjí/exportuje |
| **Triáž bugů plánovače (agent skill)** | **`.cursor/skills/ems-planner-bug-triage/`** — Infeasible/relaxed solve, večerní export, neg den, BA81/KV1; MCP SQL v `reference.md` |
---
## Konvence (krátce)
- **Dokumentace 1:1 s implementací — POVINNÉ u každé změny.** Každý commit, který mění chování, nese i aktualizaci docs ve STEJNÉM commitu (nebo bezprostředně navazujícím): plánovač → `docs/planning-changelog.md` (formát: datum · problém · příčina/mechanismus · soubory · ověření) + dotčený `docs/04-modules/*.md`; nová zařízení/registry → modulový doc (vzor `modbus-registers-teltocharge.md`); deploy/CI → `docs/deployment-self-hosted.md`; nové tabulky/sloupce → `comment on` v migraci + zmínka v `docs/03-data-model.md` u větších celků; env flagy a defaulty → místo, kde jsou popsané (např. `planning.md` sekce Verze enginu). Zastaralé tvrzení v docs = bug se stejnou prioritou jako bug v kódu.
- Python: `snake_case`, type hints, Pydantic pro API modely.
- SQL: viz také odstavec **Formát SQL** u sekce SQL-first výše — **2 mezery** odsazení, **klíčová slova malými písmeny**, `snake_case` identifikátory, explicitní FK; Flyway pořadí `V###__` / repeatable `R__NNN_*.sql` (třímístný prefix = pořadí závislostí mezi fn/vw).
- **PG funkce: žádné overloady — název `ems.fn_*` je vždy unikátní** (nikdy dvě funkce stejného jména s jinými parametry). Díky tomu se `drop function if exists` i `comment on function` píší **VŽDY bez závorky s parametry** — odkaz přes signaturu se rozbije při každé změně parametrů (42883 shodil deploy 2026-06-12, R__018: comment mířil na starou signaturu po přidání `p_force`).
- Timescale **continuous aggregate** (CA): komentář k objektu CA je **`COMMENT ON VIEW`**, ne `COMMENT ON MATERIALIZED VIEW` (PG hlásí 42809). Viz `.cursor/rules/timescale-continuous-aggregate.mdc`.
- Výkon **W**, energie **Wh**, ceny **Kč/kWh**; čas v DB **`TIMESTAMPTZ` (UTC)**.
- NIKDY neupravuj existující V__ migrační soubory po jejich aplikaci na DB.
- Pokud je potřeba opravit chybu ve verzované migraci, vytvoř novou V{N+1} migraci.
- **Vývojová kadence (slabý server):** běžná práce na větvi **`dev`** (push = CI validace BEZ deploye); do `main` merge **1×/den v okně ~16:3017:00** nebo při milníku (ne těsně před 15:00 — daily plán; OTE importy 13:2514:00). Deploy zastavuje backend na ~10 min (vynechané rolling ticky kryje Loxone fallback). Hotfix smí na main okamžitě.
- Deploy: `flyway validate` před `migrate` ([`deploy/deploy.sh`](deploy/deploy.sh)). Lokálně `./scripts/flyway_validate_local.sh`; CI viz [`docs/deployment-self-hosted.md`](docs/deployment-self-hosted.md) a `scripts/ci_check_migration_immutability.sh`.

View File

@@ -45,6 +45,18 @@ class Settings(BaseSettings):
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
planning_cheap_price_threshold: float = Field(default=0.85)
planning_expensive_price_threshold: float = Field(default=1.15)
# Discord bot — fáze B tlačítka (docs/discord-ev-interaction.md); prázdné = jen webhook
discord_bot_token: str = Field(default="")
discord_ev_channel_id: str = Field(default="")
discord_allowed_user_ids: str = Field(default="")
# Tesla Fleet API (docs/tesla-fleet-api.md); prázdné = integrace vypnutá
tesla_client_id: str = Field(default="")
tesla_client_secret: str = Field(default="")
tesla_refresh_token: str = Field(default="")
planning_engine_version: str = Field(default="v1")
planning_engine_compare_enabled: bool = Field(default=False)
@lru_cache

View File

@@ -30,6 +30,7 @@ from services.signal_service import (
run_signal_outbound_send_for_active_sites,
run_signal_outbound_verify_for_active_sites,
)
from services.ev_presence_notify import run_ev_presence_nudge_for_all_active_sites
logger = logging.getLogger(__name__)
@@ -108,7 +109,7 @@ async def lifespan(app: FastAPI):
for site in await _active_site_rows(conn):
try:
n = await conn.fetchval(
"SELECT ems.fn_fill_forecast_accuracy($1, 48)",
"SELECT ems.fn_fill_forecast_accuracy($1, 3)",
site["id"],
)
if n:
@@ -161,6 +162,34 @@ async def lifespan(app: FastAPI):
except Exception:
logger.exception("scheduled_signal_outbound_verify failed")
async def scheduled_ev_presence_nudge() -> None:
"""Proaktivní "auto doma + nepíchnuté + levné/přebytek → píchni ho".
SQL-first rozhodnutí + dedup v ems.fn_ev_presence_nudge_due (insert do
ev_presence_nudge_sent). Default-off per vozidlo (presence_nudge_enabled),
takže job běží inertně, dokud se na nějakém vozidle nezapne.
"""
try:
await run_ev_presence_nudge_for_all_active_sites(app.state.pg_pool)
except Exception:
logger.exception("scheduled_ev_presence_nudge failed")
async def scheduled_pool_control() -> None:
# Bazén: SQL-first rozhodnutí (fn_pool_control_tick) — nejlevnější souvislé
# okno denního runtime + dump-load při sell<=0; zařadí POOL_PUMP_ON (jen když
# existuje signal_route). Doručení řeší signal_outbound_send. Žádné Modbus.
try:
async with app.state.pg_pool.acquire() as conn:
rows = await conn.fetch("select * from ems.fn_pool_control_tick()")
for r in rows:
logger.info(
"pool control site=%s pump=%s on=%s runtime_min=%s route=%s enq=%s",
r["site_id"], r["pump_id"], r["desired_on"],
r["runtime_min"], r["has_route"], r["enqueued"],
)
except Exception:
logger.exception("scheduled_pool_control failed")
async def scheduled_verify_modbus() -> None:
"""
Ověří příkazy ve stavu written z posledních 20 minut.
@@ -257,6 +286,27 @@ async def lifespan(app: FastAPI):
"scheduled_tuv_usage_stats site=%s failed", site["id"]
)
async def scheduled_forecast_accuracy_catchup() -> None:
"""Denní 48h catch-up (pozdní telemetrie) — 15min tick jede jen 3 h okno."""
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
try:
await conn.fetchval(
"SELECT ems.fn_fill_forecast_accuracy($1, 48)", site["id"]
)
except Exception:
logger.exception(
"forecast_accuracy catchup site=%s failed", site["id"]
)
async def scheduled_ev_usage_stats() -> None:
async with app.state.pg_pool.acquire() as conn:
try:
n = await conn.fetchval("select ems.fn_update_ev_usage_stats(60)")
logger.info("ev_usage_stats updated %s rows", n)
except Exception:
logger.exception("scheduled_ev_usage_stats failed")
async def scheduled_forecast_refresh() -> None:
async with app.state.pg_pool.acquire() as conn:
for site in await _active_site_rows(conn):
@@ -392,6 +442,22 @@ async def lifespan(app: FastAPI):
id="signal_outbound_verify",
replace_existing=True,
)
scheduler.add_job(
scheduled_pool_control,
"cron",
minute="*/15",
second=2,
id="pool_control",
replace_existing=True,
)
scheduler.add_job(
scheduled_ev_presence_nudge,
"cron",
minute="5,30,55",
second=10,
id="ev_presence_nudge",
replace_existing=True,
)
scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan")
scheduler.add_job(
scheduled_rolling_replan,
@@ -423,6 +489,22 @@ async def lifespan(app: FastAPI):
id="tuv_usage_stats",
replace_existing=True,
)
scheduler.add_job(
scheduled_forecast_accuracy_catchup,
"cron",
hour=5,
minute=50,
id="forecast_accuracy_catchup",
replace_existing=True,
)
scheduler.add_job(
scheduled_ev_usage_stats,
"cron",
hour=0,
minute=50,
id="ev_usage_stats",
replace_existing=True,
)
scheduler.add_job(
scheduled_ote_import,
"cron",
@@ -523,6 +605,11 @@ async def lifespan(app: FastAPI):
telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool))
app.state.telemetry_task = telemetry_task
from services.discord_bot import run_discord_bot, set_pool as discord_set_pool
discord_set_pool(app.state.pg_pool)
discord_task = asyncio.create_task(run_discord_bot())
app.state.discord_task = discord_task
yield
@@ -531,6 +618,11 @@ async def lifespan(app: FastAPI):
logging.getLogger().removeHandler(ws_h)
app.state.ws_log_handler = None
discord_task.cancel()
try:
await discord_task
except (asyncio.CancelledError, Exception):
pass
telemetry_task.cancel()
try:
await telemetry_task

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import logging
import os
from datetime import datetime, timezone
@@ -24,6 +25,7 @@ from app.ws_manager import manager
from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from services.control_exporter import export_setpoints
from services.notification_service import run_fn_set_mode_with_discord
logger = logging.getLogger(__name__)
@@ -248,6 +250,31 @@ async def set_site_mode(
except Exception as e:
logger.warning("Loxone EMS_Mode notify failed for site %s: %s", site_id, e)
# Okamžitá exekuce nového režimu: control exporter jinak běží jen v minutách
# 14/29/44/59, takže by střídač až ~15 min jel podle starého plánu (např.
# SELF_SUSTAIN má hned nastavit 108/109 na max A a vypnout přetoky).
# Fire-and-forget — API neblokuje Modbus zápisy; chyby jen do logu.
asyncio.create_task(_export_setpoints_after_mode_change(db, site_id, mode_code))
return SetSiteModeResponse(
success=True, mode=mode_code, activated_at=activated_at
)
async def _export_setpoints_after_mode_change(
pool: asyncpg.Pool, site_id: int, mode_code: str
) -> None:
try:
async with pool.acquire() as conn:
await export_setpoints(site_id, conn)
logger.info(
"Immediate control export after mode change applied (site=%s, mode=%s)",
site_id,
mode_code,
)
except Exception:
logger.exception(
"Immediate control export after mode change failed (site=%s, mode=%s)",
site_id,
mode_code,
)

View File

@@ -40,7 +40,10 @@ HEARTBEAT_STALE_SEC = 300
EXPECTED_TOMORROW_PRICE_SLOTS = 90
def _iso_utc(dt: datetime | None) -> str | None:
def _iso_utc(dt: datetime | str | None) -> str | None:
# JSONB bundle z fn_site_full_status nese timestampy jako stringy — parsovat,
# jinak .tzinfo na str = AttributeError → 500 celého /status/full.
dt = _parse_ts(dt)
if dt is None:
return None
if dt.tzinfo is None:

View File

@@ -41,12 +41,109 @@ class PlanningIntervalDto(BaseModel):
)
class CurrentPlanResponseModel(BaseModel):
class PlanningBundleDto(BaseModel):
run: dict[str, Any]
intervals: list[PlanningIntervalDto]
summary: dict[str, Any]
class CurrentPlanResponseModel(PlanningBundleDto):
pass
class ComparisonSlotDiffDto(BaseModel):
interval_start: str
active: dict[str, Any]
comparison: dict[str, Any]
class PlanningCompareResponseModel(BaseModel):
active: PlanningBundleDto
comparison: PlanningBundleDto
diff: dict[str, Any]
slot_diffs: list[ComparisonSlotDiffDto]
def _bundle_from_payload(payload: dict[str, Any], *, run_key: str) -> PlanningBundleDto:
run_raw = payload.get(run_key) or {}
if not isinstance(run_raw, dict):
run_raw = {}
intervals_raw = payload.get("intervals") or []
if not isinstance(intervals_raw, list):
intervals_raw = []
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
summary = payload.get("summary") or {}
if not isinstance(summary, dict):
summary = {}
return PlanningBundleDto(run=run_raw, intervals=intervals, summary=summary)
def _bundle_from_current(payload: dict[str, Any]) -> PlanningBundleDto:
return _bundle_from_payload(payload, run_key="run")
def _bundle_from_debug(payload: dict[str, Any]) -> PlanningBundleDto:
return _bundle_from_payload(payload, run_key="planning_run")
def _build_plan_diff(
active: PlanningBundleDto,
comparison: PlanningBundleDto,
) -> tuple[dict[str, Any], list[ComparisonSlotDiffDto]]:
active_by_ts = {i.interval_start: i for i in active.intervals}
compare_by_ts = {i.interval_start: i for i in comparison.intervals}
diffs: list[ComparisonSlotDiffDto] = []
interesting_keys = (
"battery_setpoint_w",
"battery_soc_target_pct",
"grid_setpoint_w",
"export_limit_w",
"export_mode",
"deye_physical_mode",
"deye_gen_cutoff_enabled",
"pv_a_curtailed_w",
"expected_cost_czk",
)
for ts, a in active_by_ts.items():
b = compare_by_ts.get(ts)
if b is None:
continue
active_payload = a.model_dump()
comparison_payload = b.model_dump()
if any(active_payload.get(k) != comparison_payload.get(k) for k in interesting_keys):
diffs.append(
ComparisonSlotDiffDto(
interval_start=ts,
active={k: active_payload.get(k) for k in interesting_keys},
comparison={k: comparison_payload.get(k) for k in interesting_keys},
)
)
def _summary_num(bundle: PlanningBundleDto, key: str) -> float:
raw = bundle.summary.get(key)
try:
return float(raw) if raw is not None else 0.0
except (TypeError, ValueError):
return 0.0
active_cost = _summary_num(active, "total_expected_cost_czk")
compare_cost = _summary_num(comparison, "total_expected_cost_czk")
diff = {
"active_total_expected_cost_czk": active_cost,
"comparison_total_expected_cost_czk": compare_cost,
"total_expected_cost_czk": round(active_cost - compare_cost, 4),
"absolute_total_expected_cost_czk": round(abs(active_cost - compare_cost), 4),
"active_charge_slots": int(_summary_num(active, "charge_slots")),
"comparison_charge_slots": int(_summary_num(comparison, "charge_slots")),
"active_discharge_slots": int(_summary_num(active, "discharge_slots")),
"comparison_discharge_slots": int(_summary_num(comparison, "discharge_slots")),
"active_export_slots": int(_summary_num(active, "export_slots")),
"comparison_export_slots": int(_summary_num(comparison, "export_slots")),
"changed_slots": len(diffs),
}
return diff, diffs
@router.get("/current", response_model=CurrentPlanResponseModel)
async def get_current_plan(
site_id: int,
@@ -69,14 +166,54 @@ async def get_current_plan(
if bundle.get("error") == "no_active_plan":
raise HTTPException(status_code=404, detail="No active plan")
intervals_raw = bundle.get("intervals") or []
if not isinstance(intervals_raw, list):
intervals_raw = []
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
plan = _bundle_from_current(bundle)
return CurrentPlanResponseModel(
run=bundle.get("run") or {},
intervals=intervals,
summary=bundle.get("summary") or {},
run=plan.run,
intervals=plan.intervals,
summary=plan.summary,
)
@router.get("/compare", response_model=PlanningCompareResponseModel)
async def get_plan_compare(
site_id: int,
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
) -> PlanningCompareResponseModel:
async with pool.acquire() as conn:
site_ok = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
)
if not site_ok:
raise HTTPException(status_code=404, detail="Site not found")
payload = await fetch_json(
conn,
"select ems.fn_plan_compare_bundle($1::int)",
site_id,
)
if not isinstance(payload, dict):
payload = json.loads(payload)
err = payload.get("error")
if err == "no_active_plan":
raise HTTPException(status_code=404, detail="No active plan")
if err == "no_comparison_plan":
raise HTTPException(status_code=404, detail="No comparison plan")
active_raw = payload.get("active") or {}
compare_raw = payload.get("comparison")
if not isinstance(active_raw, dict):
active_raw = {}
if not isinstance(compare_raw, dict):
raise HTTPException(status_code=404, detail="No comparison plan")
active = _bundle_from_current(active_raw)
comparison = _bundle_from_current(compare_raw)
diff, slot_diffs = _build_plan_diff(active, comparison)
return PlanningCompareResponseModel(
active=active,
comparison=comparison,
diff=diff,
slot_diffs=slot_diffs,
)

View File

@@ -147,6 +147,12 @@ async def patch_pv_forecast_calibration(
status_code=404,
detail="PV forecast calibration row missing; run migration V057",
)
await conn.execute(
# p_force=true: uživatel právě změnil kalibraci — throttle 6 h nesmí
# nechat starou cache (čtenář ji od HOTFIXu 2/2 vrací bez přepočtu)
"select ems.fn_refresh_site_pv_delta_profile_cache($1::int, true)",
site_id,
)
row = await conn.fetchrow(
"""
SELECT to_jsonb(c.*) AS j

View File

@@ -414,6 +414,8 @@ class ModbusJournalCommandRow(BaseModel):
status: str
attempt_count: int
created_at: str
asset_code: str | None = None
error_msg: str | None = None
class ModbusJournalListResponse(BaseModel):

View File

@@ -12,3 +12,4 @@ pvlib>=0.11.0
pandas>=2.2.0
numpy>=2.0.0
httpx>=0.28.0
discord.py>=2.4.0

View File

@@ -1,3 +1,3 @@
"""Deye / Modbus control export (monolith v exporter_monolith.py postupný split)."""
"""Deye / Modbus control export modules."""
from .exporter_monolith import * # noqa: F401,F403

View File

@@ -0,0 +1,266 @@
"""Čisté Deye konstanty a helpery pro control export."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from services.control.models import InverterConfig
PRAGUE_TZ = ZoneInfo("Europe/Prague")
# Hodiny Deye 62-64: po zápisu sekundy na zařízení dál běží, verify musí být toleranční.
DEYE_CLOCK_VERIFY_MAX_DELTA_SEC = 120
# Řidší zápis: bez zápisu, pokud čas na invertoru neodbočí od Prahy víc než o tolik sekund.
DEYE_CLOCK_DRIFT_OK_SEC = 60
# A zároveň neuplynul tento interval od posledního syncu / potvrzení driftu.
DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24
# Deye LV baterie: převod výkon -> proud pro registry 108/109.
BATT_VOLTAGE_V = 51.2
# Reg 178 - bitové pole: bity 4-5 (peak shaving switch) a bity 0-1 (MI export cutoff).
REG178_SELL = 0b00100000
REG178_PASSIVE = 0b00110000
REG178_VERIFY_MASK = 0x0030
REG178_MI_EXPORT_MASK = 0x0003
REG178_MI_EXPORT_DISABLE = 0b10
REG178_MI_EXPORT_ENABLE = 0b11
REG178_VERIFY_MASK_COMBINED = REG178_VERIFY_MASK | REG178_MI_EXPORT_MASK
DEYE_CRITICAL_REGS_SELF_SUSTAIN = frozenset({108, 109, 142, 143, 145})
DEYE_TOU_POWER_REGS = frozenset(range(154, 160))
DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A = 350
# Neaktivní TOU bloky (3-6): Deye často 23:59 (2359) neuloží, 23:55 je stabilní.
DEYE_TOU_INACTIVE_HHMM = 2355
_DEYE_INACTIVE_TOU_REGISTERS: frozenset[int] = frozenset(
[
150,
151,
152,
153,
156,
157,
158,
159,
168,
169,
170,
171,
174,
175,
176,
177,
]
)
DEYE_CLOCK_REGS: frozenset[int] = frozenset({62, 63, 64})
DEYE_REGISTER_NAMES: dict[int, str] = {
108: "max_charge_a (max nabíjecí proud baterie)",
109: "max_discharge_a (max vybíjecí proud baterie)",
141: "energy_mode (0, EMS nemění)",
142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)",
143: "export_limit_w (max export do sítě)",
145: "solar_sell (0=disabled, 1=enabled)",
340: "max_solar_power_w (strop DC PV A v W; cap z fn_inverter_pv_a_max_w / deye_reg340_max_solar_w)",
178: "control_board_special_1 (bits0-1: MI export cutoff disable=2 enable=3; bits4-5 peak shaving 32/48)",
148: "time_point_1_time",
149: "time_point_2_time",
154: "time_point_1_power_w",
155: "time_point_2_power_w",
166: "time_point_1_soc_min_pct",
167: "time_point_2_soc_min_pct",
172: "time_point_1_grid_charge",
173: "time_point_2_grid_charge",
62: "system_time_year_month",
63: "system_time_day_hour",
64: "system_time_min_sec",
}
for _tp_i in range(6):
_n = _tp_i + 1
DEYE_REGISTER_NAMES.setdefault(148 + _tp_i, f"time_point_{_n}_time")
DEYE_REGISTER_NAMES.setdefault(154 + _tp_i, f"time_point_{_n}_power_w")
DEYE_REGISTER_NAMES.setdefault(166 + _tp_i, f"time_point_{_n}_soc_min_pct")
DEYE_REGISTER_NAMES.setdefault(172 + _tp_i, f"time_point_{_n}_grid_charge")
def _deye_reg178_verify_match(expected_i: int, actual_i: int) -> bool:
return (int(expected_i) & REG178_VERIFY_MASK_COMBINED) == (
int(actual_i) & REG178_VERIFY_MASK_COMBINED
)
def deye_mi_export_cutoff_want_enabled(
*,
gen_microinverter_cutoff_enabled: bool,
deye_gen_cutoff_enabled: bool,
export_ban: bool,
deye_mode: str,
) -> bool:
"""
True = zapnout MI export cut-off (reg 178 bits 01 = 11b).
Plán může mít z_gen_cutoff=0 (PV B jen do domu v LP), ale bez cut-off na GEN portu
mikroinvertory fyzicky exportují do sítě — při export_ban (záporná vykupní, grid≥0)
cut-off vynutit i bez solver flagu.
"""
if not gen_microinverter_cutoff_enabled:
return False
if deye_mode == "SELL":
return False
return bool(deye_gen_cutoff_enabled) or bool(export_ban)
def deye_reg_triggers_self_sustain_after_verify_exhaust(reg: int) -> bool:
"""True = po 3x mismatch přepnout lokalitu do SELF_SUSTAIN (kritický registr)."""
return int(reg) in DEYE_CRITICAL_REGS_SELF_SUSTAIN
def _deye_tou_power_verify_match(
expected_i: int, actual_i: int, inv: InverterConfig
) -> bool:
"""Firmware často clampne TOU power W na max z reg. 108/109 x 51.2 V."""
if int(actual_i) == int(expected_i):
return True
max_w_charge = int(inv.max_charge_a * BATT_VOLTAGE_V)
max_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
a = int(actual_i)
return a == max_w_charge or a == max_w_discharge
def _deye_reg178_verify_with_double_read(
expected_i: int, actual_first: int, actual_second: int | None
) -> tuple[bool, int]:
"""
Vrátí (shoda, hodnota_pro_journal).
Druhé čtení použít jen když první neprojde maskou (RS485 / glitch).
"""
if _deye_reg178_verify_match(expected_i, actual_first):
return True, actual_first
if actual_second is not None and _deye_reg178_verify_match(expected_i, actual_second):
return True, int(actual_second)
return False, actual_first
def watts_to_amps(power_w: int | None, phases: int = 3, voltage: int = 230) -> int:
# round(), NE int(): 11 kW / (3×230) = 15.94 A → int useklo na 15 A (~10.35 kW,
# 6 % výkonu); round dá správných 16 A (~11 kW). Strop 32 A drží horní mez.
if not power_w or power_w <= 0:
return 0
return min(32, max(0, round(power_w / (phases * voltage))))
def battery_watts_to_amps(power_w: int, max_amps: int) -> int:
"""Proud z |výkonu| baterie; max_amps z DB."""
derived = int(abs(power_w) / BATT_VOLTAGE_V)
return min(max(0, max_amps), max(0, derived))
def current_slot_hhmm() -> int:
"""Začátek probíhajícího 15min slotu v Europe/Prague, formát HHMM."""
now = datetime.now(PRAGUE_TZ)
slot_min = (now.minute // 15) * 15
return now.hour * 100 + slot_min
def next_slot_hhmm() -> int:
"""Začátek příštího 15min slotu v Europe/Prague, formát HHMM."""
now = datetime.now(PRAGUE_TZ)
minutes = now.minute
slot_minutes = ((minutes // 15) + 1) * 15
if slot_minutes >= 60:
next_hour = (now.hour + 1) % 24
next_min = 0
else:
next_hour = now.hour
next_min = slot_minutes
return next_hour * 100 + next_min
def compute_pv_a_reg340_max_solar_w(
cap_w: int,
forecast_w: int,
curtail_w: int,
*,
min_w: int = 0,
) -> int:
"""Hodnota pro Deye reg 340 (max solar power, W) z capu a plánovaného curtailmentu pole A."""
if curtail_w <= 0:
raw = int(cap_w)
else:
raw = max(0, min(int(cap_w), int(forecast_w) - int(curtail_w)))
if raw > 0 and int(min_w) > 0:
return max(int(min_w), raw)
return raw
def _prague_minute_start_utc() -> datetime:
"""UTC okamžik odpovídající začátku aktuální kalendářní minuty v Europe/Prague."""
p = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
return p.astimezone(timezone.utc)
def _deye_registers_to_prague_datetime(r62: int, r63: int, r64: int) -> datetime | None:
"""Dekódování reg 62-64 (Deye system time v Europe/Prague)."""
try:
year = (int(r62) >> 8) + 2000
month = int(r62) & 0xFF
day = int(r63) >> 8
hour = int(r63) & 0xFF
minute = int(r64) >> 8
second = int(r64) & 0xFF
if not (1 <= month <= 12 and 1 <= day <= 31 and 0 <= hour <= 23):
return None
if not (0 <= minute <= 59 and 0 <= second <= 59):
return None
return datetime(year, month, day, hour, minute, second, tzinfo=PRAGUE_TZ)
except (ValueError, OverflowError):
return None
def _deye_clock_registers_verify_match(
w62: int,
w63: int,
w64: int,
a62: int,
a63: int,
a64: int,
) -> bool:
w_dt = _deye_registers_to_prague_datetime(w62, w63, w64)
a_dt = _deye_registers_to_prague_datetime(a62, a63, a64)
if w_dt is None or a_dt is None:
return False
return abs((a_dt - w_dt).total_seconds()) <= DEYE_CLOCK_VERIFY_MAX_DELTA_SEC
def _deye_should_skip_time_sync_after_read(
inv: InverterConfig,
r62: int,
r63: int,
r64: int,
) -> bool:
"""
True = nezařazovat zápis 62-64: drift je malý a od posledního úspěšného zápisu
nebo tolerančního ověření neuplynulo 24h.
"""
dev = _deye_registers_to_prague_datetime(r62, r63, r64)
if dev is None:
return False
wall = datetime.now(PRAGUE_TZ)
drift = abs((wall - dev).total_seconds())
if drift > DEYE_CLOCK_DRIFT_OK_SEC:
return False
last_write = inv.deye_last_system_time_sync_at
if last_write is None:
return False
if last_write.tzinfo is None:
last_write = last_write.replace(tzinfo=timezone.utc)
else:
last_write = last_write.astimezone(timezone.utc)
age = datetime.now(timezone.utc) - last_write
if age >= timedelta(hours=DEYE_CLOCK_RESYNC_INTERVAL_HOURS):
return False
return True

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,378 @@
"""Deye inverter writer and live register reader."""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
import asyncpg
from services.control.deye_helpers import (
BATT_VOLTAGE_V,
DEYE_CLOCK_DRIFT_OK_SEC,
DEYE_CLOCK_REGS,
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
DEYE_TOU_INACTIVE_HHMM,
PRAGUE_TZ,
REG178_MI_EXPORT_DISABLE,
REG178_MI_EXPORT_ENABLE,
REG178_MI_EXPORT_MASK,
REG178_PASSIVE,
REG178_SELL,
REG178_VERIFY_MASK,
REG178_VERIFY_MASK_COMBINED,
_DEYE_INACTIVE_TOU_REGISTERS,
_deye_should_skip_time_sync_after_read,
deye_mi_export_cutoff_want_enabled,
_prague_minute_start_utc,
current_slot_hhmm,
next_slot_hhmm,
)
from services.control.modbus_journal import (
_drop_registers_matching_last_verified,
_fetch_last_verified_inverter_registers,
create_modbus_commands,
execute_modbus_commands,
)
from services.control.models import ControlSetpoints
from services.control.repository import _get_current_soc, _load_inverter_config
from services.control.setpoints import (
_deye_reg143_export_w,
_deye_system_time_register_rows,
_deye_time_point_rows,
_deye_tou_min_soc_pct,
_deye_tou_params,
_deye_tou_reserve_soc_pct,
deye_battery_charge_discharge_amps,
get_deye_mode,
)
from services.modbus_client import get_modbus_client
logger = logging.getLogger(__name__)
async def write_inverter_setpoints(
site_id: int,
setpoints_now: ControlSetpoints,
setpoints_next: ControlSetpoints | None,
db: asyncpg.Connection,
planning_run_id: int | None = None,
) -> str:
inv = await _load_inverter_config(site_id, db)
if inv is None:
return "FAIL inverter: no controllable Modbus endpoint"
unit_id = int(inv.unit_id if inv.unit_id is not None else 1)
raw_bat = setpoints_now.battery_w
grid_w = int(setpoints_now.grid_setpoint_w or 0)
no_export = inv.no_export
export_lim_hw = _deye_reg143_export_w(no_export, inv.max_export_power_w)
export_lim = export_lim_hw
if int(setpoints_now.grid_export_limit or 0) > 0:
export_lim = min(export_lim_hw, int(setpoints_now.grid_export_limit))
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints_now.lock_battery else max_batt_w_discharge
tou_min_pct = _deye_tou_min_soc_pct(inv)
tou_reserve_pct = _deye_tou_reserve_soc_pct(inv)
try:
soc_telemetry = await _get_current_soc(site_id, db)
deye_mode = get_deye_mode(setpoints_now)
bat_w = int(raw_bat) if raw_bat is not None else 0
charge_a, discharge_a = deye_battery_charge_discharge_amps(
lock_battery=setpoints_now.lock_battery,
deye_mode=deye_mode,
self_sustain_local_use=setpoints_now.self_sustain_local_use,
bat_w=bat_w,
grid_w=grid_w,
max_charge_a=int(inv.max_charge_a),
max_discharge_a=int(inv.max_discharge_a),
export_mode=setpoints_now.export_mode,
export_ban=bool(setpoints_now.export_ban),
current_soc_pct=soc_telemetry,
max_soc_pct=inv.max_soc_percent,
)
zero_exp_mode = int(inv.deye_zero_export_mode or 1)
selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode
solar_sell = 0 if (setpoints_now.export_ban and deye_mode != "SELL") else 1
export_limit = export_lim
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
charge_a_log = charge_a if charge_a is not None else "skip"
logger.info(
f"[control] site={site_id} fyzický režim Deye: {deye_mode} | "
f"battery_w={raw_bat!r} grid_w={grid_w} | "
f"charge_a={charge_a_log} discharge_a={discharge_a} | "
f"reg142={selling_mode} reg145={solar_sell} reg143={export_limit}W reg178={reg178_val}"
)
now_cet, time_rows = _deye_system_time_register_rows()
skip_time = False
try:
mb_clock = await get_modbus_client(inv.host, inv.port)
tvals = await mb_clock.read_holding_registers(
62, 3, int(inv.unit_id if inv.unit_id is not None else 1)
)
if len(tvals) == 3:
skip_time = _deye_should_skip_time_sync_after_read(
inv, int(tvals[0]), int(tvals[1]), int(tvals[2])
)
else:
logger.warning(
"Deye clock read: expected 3 registers, got %s; will sync 62-64",
len(tvals),
)
except Exception as e:
logger.warning("Deye clock read failed (will sync 62-64): %s", e)
if skip_time:
logger.info(
"Deye clock 62-64 skipped (drift <= %ss, last sync < %sh ago): %s CET",
DEYE_CLOCK_DRIFT_OK_SEC,
DEYE_CLOCK_RESYNC_INTERVAL_HOURS,
now_cet.strftime("%Y-%m-%d %H:%M:%S"),
)
else:
logger.info("Deye time will sync: %s CET", now_cet.strftime("%Y-%m-%d %H:%M:%S"))
registers: list[tuple[int, str, int]] = [] if skip_time else list(time_rows)
sp_tp2 = setpoints_next if setpoints_next is not None else setpoints_now
hh_cur = current_slot_hhmm()
hh_nxt = next_slot_hhmm()
p1, s1, g1 = _deye_tou_params(setpoints_now, inv)
p2, s2, g2 = _deye_tou_params(sp_tp2, inv)
registers.extend(_deye_time_point_rows(0, hh_cur, p1, s1, g1))
registers.extend(_deye_time_point_rows(1, hh_nxt, p2, s2, g2))
prague_date = datetime.now(PRAGUE_TZ).date()
inactive_sig = (
f"{DEYE_TOU_INACTIVE_HHMM}|{tou_min_pct}|{tou_reserve_pct}|{tp_discharge_w}"
)
need_inactive_tou = (
inv.deye_last_tou_inactive_write_prague_date != prague_date
or inv.deye_tou_inactive_signature != inactive_sig
)
if need_inactive_tou:
for idx in range(2, 6):
registers.extend(
_deye_time_point_rows(
idx, DEYE_TOU_INACTIVE_HHMM, tp_discharge_w, tou_min_pct, False
)
)
else:
logger.debug(
"Deye TOU rows 3-6 skipped (already written today, signature unchanged)"
)
amp_regs: list[tuple[int, str, int]] = []
if charge_a is not None:
amp_regs.append((108, "", charge_a))
amp_regs.append((109, "", discharge_a))
registers.extend(
amp_regs
+ [
(141, "energy_mode (0)", 0),
(142, "limit_control", selling_mode),
(143, "", export_limit),
(145, "solar_sell", solar_sell),
]
)
if (
bool(inv.deye_reg340_pv_a_control_enabled)
and int(inv.pv_a_cap_w) > 0
and setpoints_now.pv_a_allowed_w is not None
):
registers.append((340, "max_solar_power_w", int(setpoints_now.pv_a_allowed_w)))
try:
mb178 = await get_modbus_client(inv.host, inv.port)
r178 = await mb178.read_holding_registers(178, 1, unit_id)
if not r178 or len(r178) < 1:
raise OSError(f"reg178 read returned {len(r178) if r178 is not None else None} values")
current_178 = int(r178[0])
peak_bits = int(reg178_val) & int(REG178_VERIFY_MASK)
if inv.deye_gen_microinverter_cutoff_enabled:
want_cutoff = deye_mi_export_cutoff_want_enabled(
gen_microinverter_cutoff_enabled=True,
deye_gen_cutoff_enabled=bool(setpoints_now.deye_gen_cutoff_enabled),
export_ban=bool(setpoints_now.export_ban),
deye_mode=deye_mode,
)
mi_bits = REG178_MI_EXPORT_ENABLE if want_cutoff else REG178_MI_EXPORT_DISABLE
else:
mi_bits = int(current_178) & int(REG178_MI_EXPORT_MASK)
new_178 = (
(int(current_178) & ~int(REG178_VERIFY_MASK_COMBINED))
| int(peak_bits)
| int(mi_bits)
)
registers.append((178, "control_board_special_1", int(new_178)))
logger.info(
"[control] %s: reg178 (control_board_special_1) old=%s new=%s peak_bits=0x%04X mi_bits=%s",
inv.code,
current_178,
new_178,
int(peak_bits),
int(mi_bits),
)
except Exception as e:
logger.warning("[control] %s: reg178 RMW failed (skip reg178 write): %s", inv.code, e)
logger.info(
"[control] %s: deye_mode=%s charge=%sA discharge=%sA "
"reg142=%s reg145=%s export=%sW "
"tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)",
inv.code,
deye_mode,
charge_a,
discharge_a,
selling_mode,
solar_sell,
export_limit,
hh_cur,
hh_nxt,
soc_telemetry,
raw_bat,
grid_w,
)
last_verified = await _fetch_last_verified_inverter_registers(site_id, inv.id, db)
registers, skipped_unchanged = _drop_registers_matching_last_verified(
registers, last_verified
)
if skipped_unchanged:
logger.info(
"[control] %s: skip %s registers (value equals last verified): %s",
inv.code,
len(skipped_unchanged),
skipped_unchanged[:24],
)
if not registers:
logger.info(
"[control] %s: all Deye holding regs match last verified, no Modbus write",
inv.code,
)
if need_inactive_tou:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_tou_inactive_write_prague_date = $1,
deye_tou_inactive_signature = $2
WHERE id = $3
""",
prague_date,
inactive_sig,
inv.id,
)
return (
f"OK inverter: batt_w={raw_bat!r} (no changes vs last verified Modbus snapshot)"
)
will_write_inactive = any(
int(r) in _DEYE_INACTIVE_TOU_REGISTERS for r, _, _ in registers
)
cmd_ids = await create_modbus_commands(
site_id,
planning_run_id,
"inverter",
inv.id,
inv.code,
inv.host,
inv.port,
inv.unit_id,
registers,
db,
deye_physical_mode=deye_mode,
)
if not await execute_modbus_commands(cmd_ids, db):
return f"FAIL inverter: {inv.code}: Modbus write failed (see modbus_command)"
logger.info("[control] Inverter %s journal write OK", inv.code)
will_write_time = any(int(r) in DEYE_CLOCK_REGS for r, _, _ in registers)
if will_write_time:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_system_time_sync_minute = $1,
deye_last_system_time_sync_at = now()
WHERE id = $2
""",
_prague_minute_start_utc(),
inv.id,
)
if need_inactive_tou or will_write_inactive:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_tou_inactive_write_prague_date = $1,
deye_tou_inactive_signature = $2
WHERE id = $3
""",
prague_date,
inactive_sig,
inv.id,
)
except Exception as e:
return f"FAIL inverter: {inv.code}: {e}"
return (
f"OK inverter: batt_w={raw_bat!r} "
f"(time points + FC 0x10: 108/109/141/142/178/143/145/340 dle plánu)"
)
async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]:
"""
Živé čtení holding registrů Deye 108, 109, 141-145, 178, 191 a volitelně 340.
"""
inv = await _load_inverter_config(site_id, db)
if inv is None:
raise ValueError("no controllable Modbus inverter for site")
uid = int(inv.unit_id)
client = await get_modbus_client(inv.host, inv.port)
read_at = datetime.now(timezone.utc)
try:
async with client.batch(uid) as mb:
b108 = await mb.read_holding_registers(108, 2)
b141 = await mb.read_holding_registers(141, 5)
r178 = await mb.read_holding_registers(178, 1)
r191 = await mb.read_holding_registers(191, 1)
if inv.deye_reg340_pv_a_control_enabled:
r340 = await mb.read_holding_registers(340, 1)
else:
r340 = None
r108, r109 = b108[0], b108[1]
r141, r142, r143 = b141[0], b141[1], b141[2]
r145 = b141[4]
r178 = r178[0]
r191 = r191[0]
r340v = int(r340[0]) if r340 is not None and len(r340) >= 1 else None
except Exception:
logger.exception("read_deye_registers_live site=%s failed", site_id)
raise
return {
"reg108_charge_a": int(r108),
"reg109_discharge_a": int(r109),
"reg141_energy_mode": int(r141),
"reg142_limit_control": int(r142),
"reg143_export_limit_w": int(r143),
"reg145_solar_sell": int(r145),
"reg178_peak_shaving_switch": int(r178),
"reg178_control_board_special_1": int(r178),
"reg178_mi_export_cutoff_bits": int(r178) & int(REG178_MI_EXPORT_MASK),
"reg178_mi_export_cutoff_is_on": (int(r178) & int(REG178_MI_EXPORT_MASK))
== int(REG178_MI_EXPORT_ENABLE),
"reg191_peak_shaving_w": int(r191),
"reg340_max_solar_power_w": r340v,
"read_at": read_at.isoformat(),
}

View File

@@ -0,0 +1,349 @@
"""Modbus command journal helpers pro control export."""
from __future__ import annotations
import asyncio
import json
import logging
from collections import defaultdict
import asyncpg
from services.control.deye_helpers import DEYE_REGISTER_NAMES, _deye_reg178_verify_match
from services.modbus_client import get_modbus_client
logger = logging.getLogger(__name__)
async def _fetch_written_deye_clock_commands(
site_id: int,
asset_id: int,
host: str,
port: int,
unit_id: int,
db: asyncpg.Connection,
) -> list[asyncpg.Record]:
"""Všechny řádky journalu 62-64 ve stavu written pro daný invertor/endpoint."""
rows = await db.fetch(
"""
SELECT * FROM ems.modbus_command
WHERE site_id = $1
AND asset_type = 'inverter'
AND asset_id = $2
AND device_host = $3
AND device_port = $4
AND device_unit_id = $5
AND register IN (62, 63, 64)
AND status = 'written'
ORDER BY register
""",
site_id,
asset_id,
host,
port,
unit_id,
)
return list(rows)
async def _fetch_last_verified_registers(
site_id: int,
asset_id: int,
db: asyncpg.Connection,
*,
asset_type: str = "inverter",
) -> dict[int, int]:
"""
Poslední hodnota na zařízení podle journalu (jen status verified).
Slouží k přeskočení duplicitního zápisu stejné hodnoty.
"""
raw = await db.fetchval(
"""
select ems.fn_modbus_last_verified_map($1::int, $2::int, $3::text)
""",
site_id,
asset_id,
asset_type,
)
data = raw if isinstance(raw, dict) else json.loads(raw)
return {int(k): int(v) for k, v in data.items()}
async def _fetch_device_state_registers(
site_id: int,
asset_id: int,
db: asyncpg.Connection,
*,
asset_type: str,
) -> dict[int, int]:
"""
Poslední známá hodnota na zařízení podle journalu — NEJNOVĚJŠÍ řádek per
registr, hodnota jen pro status 'verified' nebo 'written' (zápis prošel,
verify ještě nemusel doběhnout). Novější failed/mismatch => registr chybí
=> volající zapíše znovu (obnova konfigurace po výpadku zařízení).
Pro write-on-change u EV wallboxů (EEPROM wear): na rozdíl od
_fetch_last_verified_registers nevyžaduje úspěšný verify, takže se zápis
neopakuje každý export tick, když verify čtení zaostává nebo selhává.
"""
raw = await db.fetchval(
"""
select ems.fn_modbus_device_state_map($1::int, $2::int, $3::text)
""",
site_id,
asset_id,
asset_type,
)
data = raw if isinstance(raw, dict) else json.loads(raw)
return {int(k): int(v) for k, v in data.items()}
async def _fetch_last_verified_inverter_registers(
site_id: int, inverter_asset_id: int, db: asyncpg.Connection
) -> dict[int, int]:
"""Zpětně kompatibilní alias (Deye cesty)."""
return await _fetch_last_verified_registers(
site_id, inverter_asset_id, db, asset_type="inverter"
)
def _drop_registers_matching_last_verified(
registers: list[tuple[int, str, int]],
last_verified: dict[int, int],
) -> tuple[list[tuple[int, str, int]], list[int]]:
"""Vynechá položky s hodnotou shodnou s posledním ověřeným stavem."""
out: list[tuple[int, str, int]] = []
skipped: list[int] = []
for reg, meta, val in registers:
lv = last_verified.get(int(reg))
if lv is not None:
if int(reg) == 178 and _deye_reg178_verify_match(int(val), int(lv)):
skipped.append(int(reg))
continue
if int(lv) == int(val):
skipped.append(int(reg))
continue
out.append((reg, meta, val))
return out, skipped
async def create_modbus_commands(
site_id: int,
planning_run_id: int | None,
asset_type: str,
asset_id: int,
asset_code: str,
host: str,
port: int,
unit_id: int,
registers: list[tuple[int, str, int]],
db: asyncpg.Connection,
deye_physical_mode: str | None = None,
) -> list[int]:
"""
Vytvoří záznamy v modbus_command pro sadu zápisů.
Vrátí list command IDs.
"""
ids: list[int] = []
for reg, given_name, val in registers:
# Deye registry mají kanonická jména; pro ostatní zařízení (Teltonika…)
# platí jméno dodané volajícím.
register_name = (
DEYE_REGISTER_NAMES.get(reg)
if asset_type == "inverter"
else None
) or given_name or f"reg_{reg}"
cmd_id = await db.fetchval(
"""
INSERT INTO ems.modbus_command
(site_id, asset_type, asset_id, asset_code,
device_host, device_port, device_unit_id,
register, register_name, value_to_write,
planning_run_id, status, deye_physical_mode)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'pending',$12)
RETURNING id
""",
site_id,
asset_type,
asset_id,
asset_code,
host,
port,
unit_id,
reg,
register_name,
val,
planning_run_id,
deye_physical_mode,
)
if cmd_id is not None:
ids.append(int(cmd_id))
return ids
def _modbus_command_contiguous_runs(cmds: list[asyncpg.Record]) -> list[list[asyncpg.Record]]:
"""Seřadí podle adresy registru a rozdělí na souvislé úseky pro FC 0x10 / FC 3."""
if not cmds:
return []
sorted_cmds = sorted(cmds, key=lambda c: int(c["register"]))
runs: list[list[asyncpg.Record]] = []
cur: list[asyncpg.Record] = [sorted_cmds[0]]
for c in sorted_cmds[1:]:
if int(c["register"]) == int(cur[-1]["register"]) + 1:
cur.append(c)
else:
runs.append(cur)
cur = [c]
runs.append(cur)
return runs
def _modbus_error_text(e: BaseException) -> str:
"""Text chyby pro error_msg — nikdy prázdný (TimeoutError() apod. má str '')."""
return str(e).strip() or repr(e)
async def _mark_commands_failed(
db: asyncpg.Connection, cmd_ids: list[int], error_msg: str
) -> None:
for cid in cmd_ids:
await db.execute(
"""
UPDATE ems.modbus_command
SET status='failed', error_msg=$1,
attempt_count=attempt_count+1
WHERE id=$2
""",
error_msg,
cid,
)
async def execute_modbus_commands(
command_ids: list[int],
db: asyncpg.Connection,
) -> bool:
"""
Zapíše příkazy z modbus_command do zařízení (FC 0x10 po souvislých blocích).
Aktualizuje status na 'written' nebo 'failed'.
Invariant: žádný z předaných příkazů nesmí zůstat 'pending' — i při
CancelledError / GatewayLockTimeout / chybě DB se zbylé řádky označí
failed s neprázdným error_msg (safety net níže) a výjimka se propaguje.
"""
max_retries = 3
retry_delay = 0.5
rows: list[asyncpg.Record] = []
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is not None:
rows.append(cmd)
if not rows:
return True
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
for cmd in rows:
by_gw[
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
].append(cmd)
#: Ještě nerozhodnuté příkazy (pro safety net při výjimce mimo retry cyklus).
unresolved: set[int] = {int(c["id"]) for c in rows}
all_ok = True
try:
for (host, port, unit), group in by_gw.items():
client = await get_modbus_client(host, port)
for run in _modbus_command_contiguous_runs(group):
start_reg = int(run[0]["register"])
values = [int(c["value_to_write"]) for c in run]
write_err: Exception | None = None
attempts_used = 0
for attempt in range(max_retries):
attempts_used = attempt + 1
try:
await client.write_registers(start_reg, values, unit)
write_err = None
break
except Exception as e:
write_err = e
if attempt < max_retries - 1:
logger.warning(
"Modbus batch write 0x%04X count=%s attempt %s failed: %s, retrying...",
start_reg,
len(values),
attempt + 1,
_modbus_error_text(e),
)
await asyncio.sleep(retry_delay)
try:
await client.force_disconnect()
except Exception as de:
logger.warning(
"Modbus force_disconnect %s:%s failed: %s",
host,
port,
_modbus_error_text(de),
)
if write_err is not None:
err = _modbus_error_text(write_err)
await _mark_commands_failed(db, [int(c["id"]) for c in run], err)
for c in run:
unresolved.discard(int(c["id"]))
logger.error(
"Modbus batch 0x%04X count=%s all %s attempts failed: %s",
start_reg,
len(values),
max_retries,
err,
)
all_ok = False
continue
# Journal update mimo retry cyklus — chyba DB nesmí vyvolat
# další zápis do zařízení; spadne do safety netu níže.
for cmd, val in zip(run, values):
cid = int(cmd["id"])
await db.execute(
"""
UPDATE ems.modbus_command
SET status='written', value_written=$1, written_at=now(),
attempt_count=attempt_count+1, error_msg=NULL
WHERE id=$2
""",
val,
cid,
)
unresolved.discard(cid)
logger.info(
"[cmd %s] %s 0x%04X=%s OK batch@%s (attempt %s)",
cid,
cmd["asset_code"],
int(cmd["register"]),
val,
start_reg,
attempts_used,
)
except BaseException as e:
# Safety net: CancelledError (shutdown / zrušený task), GatewayLockTimeout
# propadlý mimo retry cyklus, chyba DB v success větvi, … — nic nesmí
# zůstat 'pending'. Best effort: označit a výjimku propagovat dál.
err = f"execute aborted: {_modbus_error_text(e)}"
try:
await _mark_commands_failed(db, sorted(unresolved), err)
except Exception as me:
logger.error(
"Modbus journal: nelze označit %s příkazů failed (%s): %s",
len(unresolved),
err,
_modbus_error_text(me),
)
logger.error("execute_modbus_commands aborted: %s", err)
raise
return all_ok

View File

@@ -0,0 +1,78 @@
"""Datové modely pro control export."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime
@dataclass
class InverterConfig:
id: int
code: str
host: str
port: int
unit_id: int
max_export_power_w: int | None
max_import_power_w: int | None
no_export: bool
max_battery_charge_w: int | None
max_battery_discharge_w: int | None
min_soc_percent: int | None
reserve_soc_percent: int | None
max_soc_percent: int | None
usable_capacity_wh: int | None
max_charge_a: int
max_discharge_a: int
deye_last_system_time_sync_minute: datetime | None = None
deye_last_system_time_sync_at: datetime | None = None
deye_last_tou_inactive_write_prague_date: date | None = None
deye_tou_inactive_signature: str | None = None
deye_zero_export_mode: int = 1
deye_gen_microinverter_cutoff_enabled: bool = False
#: Strop reg 340 (W) z fn_inverter_pv_a_max_w; 0 = EMS nezapisuje reg 340.
pv_a_cap_w: int = 0
#: Minimální hodnota reg 340 přijatá firmwarem (0 nebo např. 400 u staršího Deye).
pv_a_reg340_min_w: int = 0
#: True = EMS smí řídit Deye reg 340 (max solar power); z SQL `fn_site_has_active_green_bonus_pv(site_id)`.
deye_reg340_pv_a_control_enabled: bool = False
@dataclass
class ControlSetpoints:
battery_w: int | None
#: Tvrdý limit exportu do sítě v daném slotu (W), ne forecastová cílová hodnota.
grid_export_limit: int
ev1_current_a: int
ev2_current_a: int
heat_pump_enable: bool
grid_setpoint_w: int
ev1_power_w: int
ev2_power_w: int
target_soc_pct: int | None = None
#: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE).
deye_physical_mode: str | None = None
#: Záměr exportu z LP: NONE / PV_SURPLUS / BATTERY_SELL.
export_mode: str | None = None
#: True = zákaz exportu (BLOCK_EXPORT) pro daný slot.
export_ban: bool = False
#: True = odpojit GEN port (MI export cutoff) v tomto slotu dle plánu (reg 178 bits0-1).
deye_gen_cutoff_enabled: bool = False
#: Efektivní vykupní cena slotu (Kč/kWh z plánu).
effective_sell_price_czk_kwh: float | None = None
#: True = reg 108/109 na 0 (PRESERVE - Deye baterii nepoužívá).
lock_battery: bool = False
#: Režim SELF_SUSTAIN.
self_sustain_local_use: bool = False
#: Deye reg 340 (max solar power, W). None = EMS reg 340 v tomto ticku neřeší.
pv_a_allowed_w: int | None = None
@dataclass
class OperatingModeInfo:
mode_code: str
battery_mode: str
grid_mode: str
ev_enabled: bool
heat_pump_enabled_def: bool
loxone_mode_value: int

View File

@@ -0,0 +1,165 @@
"""Top-level control export orchestration."""
from __future__ import annotations
import logging
import asyncpg
from services.control.inverter import write_inverter_setpoints
from services.control.models import ControlSetpoints
from services.control.outputs import (
send_loxone_setpoints,
write_ev_setpoints,
write_heat_pump_setpoint,
)
from services.control.repository import (
_fetch_max_charge_power_w,
_fetch_operating_mode,
_fetch_plan_row_for_slot_offset,
_load_inverter_config,
)
from services.control.setpoints import (
_apply_export_plan_guard,
_apply_price_failsafe_guard,
_build_setpoints,
)
from services.signal_service import enqueue_site_signals
logger = logging.getLogger(__name__)
async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
mode = await _fetch_operating_mode(site_id, db)
if mode is None:
logger.warning("control export site=%s: no operating mode row", site_id)
return
if mode.mode_code == "MANUAL":
logger.info("control export site=%s: MANUAL, skip writes", site_id)
return
try:
inv_for_pv = await _load_inverter_config(site_id, db)
cap_pv = int(inv_for_pv.pv_a_cap_w) if inv_for_pv is not None else 0
min_pv = int(inv_for_pv.pv_a_reg340_min_w) if inv_for_pv is not None else 0
reg340_en = (
bool(inv_for_pv.deye_reg340_pv_a_control_enabled)
if inv_for_pv is not None
else False
)
pi_now = await _fetch_plan_row_for_slot_offset(site_id, db, 0)
pi_next = await _fetch_plan_row_for_slot_offset(site_id, db, 1)
sp_now = _build_setpoints(
mode,
pi_now,
pv_a_cap_w=cap_pv,
pv_a_reg340_min_w=min_pv,
reg340_pv_a_control_enabled=reg340_en,
)
sp_next = _build_setpoints(
mode,
pi_next,
pv_a_cap_w=cap_pv,
pv_a_reg340_min_w=min_pv,
reg340_pv_a_control_enabled=reg340_en,
)
if mode.mode_code == "AUTO" and sp_now is None:
if pi_now is None:
logger.warning(
"control export site=%s: AUTO but no planning_interval for current slot, skip",
site_id,
)
return
if sp_now is None:
logger.warning(
"control export site=%s: no setpoints for mode %s, skip",
site_id,
mode.mode_code,
)
return
if mode.mode_code == "CHARGE_CHEAP":
max_ch = await _fetch_max_charge_power_w(site_id, db)
pw = max(1, int(max_ch))
sp_now = ControlSetpoints(
battery_w=pw,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=pw,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
effective_sell_price_czk_kwh=None,
)
sp_next = sp_now
else:
sp_now = _apply_export_plan_guard(site_id, mode, pi_now, sp_now)
sp_now = _apply_price_failsafe_guard(site_id, mode, pi_now, sp_now)
if sp_next is not None:
sp_next = _apply_export_plan_guard(site_id, mode, pi_next, sp_next)
sp_next = _apply_price_failsafe_guard(site_id, mode, pi_next, sp_next)
planning_run_id = await db.fetchval(
"""
SELECT id FROM ems.planning_run
WHERE site_id = $1 AND status = 'active'
ORDER BY created_at DESC
LIMIT 1
""",
site_id,
)
if planning_run_id is not None:
planning_run_id = int(planning_run_id)
try:
inv_res = await write_inverter_setpoints(
site_id, sp_now, sp_next, db, planning_run_id=planning_run_id
)
except Exception as e:
logger.error("inverter write failed: %s", e)
inv_res = f"FAIL inverter: {e}"
try:
ev_res = await write_ev_setpoints(site_id, sp_now, db)
except Exception as e:
logger.error("ev write failed: %s", e)
ev_res = f"FAIL ev: {e}"
try:
hp_res = await write_heat_pump_setpoint(site_id, sp_now, db)
except Exception as e:
logger.error("hp write failed: %s", e)
hp_res = f"FAIL heat pump: {e}"
try:
lox_res = await send_loxone_setpoints(site_id, sp_now, mode, db)
except Exception as e:
logger.error("loxone write failed: %s", e)
lox_res = f"FAIL Loxone: {e}"
results = list(
zip(
("inverter", "ev", "heat_pump", "loxone"),
(inv_res, ev_res, hp_res, lox_res),
)
)
for name, res in results:
if isinstance(res, Exception):
logger.error("control export site=%s %s: FAIL %s", site_id, name, res)
elif isinstance(res, str) and res.startswith("FAIL"):
logger.error("control export site=%s %s: %s", site_id, name, res)
else:
logger.info("control export site=%s %s: %s", site_id, name, res)
finally:
try:
await enqueue_site_signals(site_id, db)
except Exception as e:
logger.warning(
"control export site=%s: signal enqueue failed: %s", site_id, e
)

View File

@@ -0,0 +1,340 @@
"""Non-Deye output writers for control export."""
from __future__ import annotations
import logging
import os
import asyncpg
import httpx
from app.config import get_settings
from services.control.models import ControlSetpoints, OperatingModeInfo
logger = logging.getLogger(__name__)
# Teltonika TeltoCharge zápisové registry (oficiální protokol rev 0.5;
# docs/04-modules/modbus-registers-teltocharge.md). FC 16 přes journal.
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 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,
*,
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.
**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", 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":
a = sp.ev1_current_a
elif c == "ev-charger-2":
a = sp.ev2_current_a
elif c.endswith("-1") or c == "ev1":
a = sp.ev1_current_a
elif c.endswith("-2") or c == "ev2":
a = sp.ev2_current_a
else:
a = 0
if a < 6:
a = 0
return a
async def write_ev_setpoints(
site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection
) -> str:
from services.control.modbus_journal import (
_drop_registers_matching_last_verified,
_fetch_device_state_registers,
create_modbus_commands,
execute_modbus_commands,
)
rows = await db.fetch(
"""
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
AND ec.schedulable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
ORDER BY ec.code
""",
site_id,
)
if not rows:
return "OK EV: no schedulable chargers"
written = 0
for row in rows:
code = row["code"]
asset_id = int(row["asset_id"])
host = str(row["host"])
port = int(row["port"] or 502)
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,
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"
)
watchdog_regs, skipped = _drop_registers_matching_last_verified(
watchdog_regs, device_state
)
to_write = amps_regs + watchdog_regs
if not to_write:
logger.debug("EV setpoint [%s]: beze změny (%s A)", code, current_a)
continue
cmd_ids = await create_modbus_commands(
site_id,
None,
"ev_charger",
asset_id,
code,
host,
port,
unit_id,
to_write,
db,
)
ok = await execute_modbus_commands(cmd_ids, db)
written += 1
logger.info(
"EV setpoint [%s]: %s A (regs %s%s) -> %s",
code,
current_a,
[r for r, _, _ in to_write],
f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED",
)
return f"OK EV: {written}/{len(rows)} charger(s) written"
async def write_ev_arrival_hold(
site_id: int, charger_code: str, db: asyncpg.Connection
) -> bool:
"""Okamžitě po DETEKCI příjezdu zapsat 0 A na daný wallbox (přes journal).
TeltoCharge po připojení kabelu sám rozjede nabíjení svým defaultem —
nabíjet smí až PLÁN (replan + export běží hned poté v _on_ev_arrival,
takže držení trvá sekundy až ~1 min). Write-on-change: registry shodné
s posledním written/verified stavem (typicky watchdog 19/20, často
i 15=0) se přeskočí — žádný zbytečný zápis při každém píchnutí kabelu.
"""
from services.control.modbus_journal import (
_drop_registers_matching_last_verified,
_fetch_device_state_registers,
create_modbus_commands,
execute_modbus_commands,
)
row = await db.fetchrow(
"""
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
AND ec.code = $2
AND ec.schedulable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""",
site_id,
charger_code,
)
if row is None:
return False
asset_id = int(row["asset_id"])
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"
)
watchdog_regs, skipped = _drop_registers_matching_last_verified(
watchdog_regs, device_state
)
to_write = amps_regs + watchdog_regs
cmd_ids = await create_modbus_commands(
site_id,
None,
"ev_charger",
asset_id,
str(row["code"]),
str(row["host"]),
int(row["port"] or 502),
int(row["unit_id"] if row["unit_id"] is not None else 1),
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 to_write],
f", skip {skipped}" if skipped else "",
"written" if ok else "FAILED",
)
return bool(ok)
async def write_heat_pump_setpoint(
site_id: int, setpoints: ControlSetpoints, db: asyncpg.Connection
) -> str:
rows = await db.fetch(
"""
SELECT hp.code, se.host, se.port, se.unit_id
FROM ems.asset_heat_pump hp
JOIN ems.site_endpoint se ON se.id = hp.endpoint_id
WHERE hp.site_id = $1
AND hp.schedulable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
""",
site_id,
)
if not rows:
return "OK heat pump: no schedulable unit"
for row in rows:
logger.info(
"HP setpoint [%s]: enable=%s (TODO: Modbus registers)",
row["code"],
setpoints.heat_pump_enable,
)
return "OK heat pump: logged (Modbus TODO)"
async def send_loxone_setpoints(
site_id: int,
setpoints: ControlSetpoints,
mode: OperatingModeInfo,
db: asyncpg.Connection,
) -> str:
endpoint = await db.fetchrow(
"""
SELECT host, port, protocol
FROM ems.site_endpoint
WHERE site_id = $1 AND endpoint_type = 'loxone_http' AND enabled = true
ORDER BY id
LIMIT 1
""",
site_id,
)
if not endpoint:
return "OK Loxone: no endpoint, skipped"
proto = (endpoint["protocol"] or "http").lower()
if proto not in ("http", "https"):
proto = "http"
host = endpoint["host"]
port = int(endpoint["port"] or (443 if proto == "https" else 80))
base = f"{proto}://{host}:{port}/dev/sps/io"
settings = get_settings()
user = settings.loxone_user or os.getenv("LOXONE_USER") or ""
password = settings.loxone_password or os.getenv("LOXONE_PASSWORD") or ""
auth = (user, password) if user else None
batt_display = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
paths: list[tuple[str, int]] = [
(f"{base}/EMS_Mode/{mode.loxone_mode_value}", mode.loxone_mode_value),
(f"{base}/EMS_Battery_Setpoint_W/{batt_display}", batt_display),
(f"{base}/EMS_Grid_Setpoint_W/{setpoints.grid_setpoint_w}", setpoints.grid_setpoint_w),
(f"{base}/EMS_EV1_Power_W/{setpoints.ev1_power_w}", setpoints.ev1_power_w),
(f"{base}/EMS_EV2_Power_W/{setpoints.ev2_power_w}", setpoints.ev2_power_w),
(
f"{base}/EMS_HeatPump_Enable/{1 if setpoints.heat_pump_enable else 0}",
1 if setpoints.heat_pump_enable else 0,
),
]
errs: list[str] = []
try:
async with httpx.AsyncClient(timeout=5.0) as client:
for url, _ in paths:
try:
r = await client.get(url, auth=auth)
r.raise_for_status()
except Exception as e:
errs.append(f"{url!s}: {e}")
except Exception as e:
return f"FAIL Loxone: client {e}"
if errs:
return "FAIL Loxone: " + "; ".join(errs[:3])
return "OK Loxone: all virtual inputs updated"

View File

@@ -0,0 +1,217 @@
"""DB načítání pro control export."""
from __future__ import annotations
import json
from datetime import datetime, timezone
import asyncpg
from services.control.deye_helpers import DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A
from services.control.models import InverterConfig, OperatingModeInfo
from services.control.setpoints import _DictRecord
async def _fetch_operating_mode(
site_id: int, db: asyncpg.Connection
) -> OperatingModeInfo | None:
sql = """
SELECT som.mode_code, omd.battery_mode, omd.grid_mode,
omd.ev_enabled, omd.heat_pump_enabled, omd.loxone_mode_value,
som.valid_until
FROM ems.site_operating_mode som
JOIN ems.operating_mode_def omd ON omd.code = som.mode_code
WHERE som.site_id = $1
"""
row = await db.fetchrow(sql, site_id)
if row is None:
return None
vu = row["valid_until"]
if vu is not None:
now_utc = datetime.now(timezone.utc)
if vu.tzinfo is None:
vu = vu.replace(tzinfo=timezone.utc)
if vu <= now_utc:
exp_rows = await db.fetch("SELECT * FROM ems.fn_expire_modes()")
from services.notification_service import notify_operating_mode_changed
for er in exp_rows:
await notify_operating_mode_changed(
str(er["site_code"]),
str(er["old_mode"]),
str(er["new_mode"]),
"system:expiry",
"Automatické vypršení dočasného režimu",
)
row = await db.fetchrow(sql, site_id)
if row is None:
return None
return OperatingModeInfo(
mode_code=row["mode_code"],
battery_mode=row["battery_mode"],
grid_mode=row["grid_mode"],
ev_enabled=bool(row["ev_enabled"]),
heat_pump_enabled_def=bool(row["heat_pump_enabled"]),
loxone_mode_value=int(row["loxone_mode_value"]),
)
async def _get_current_soc(site_id: int, db: asyncpg.Connection) -> int:
soc = await db.fetchval(
"""
SELECT battery_soc_percent
FROM ems.telemetry_inverter
WHERE site_id = $1 AND battery_soc_percent IS NOT NULL
ORDER BY measured_at DESC
LIMIT 1
""",
site_id,
)
return int(soc) if soc is not None else 50
async def _load_inverter_config(
site_id: int, db: asyncpg.Connection
) -> InverterConfig | None:
row = await db.fetchrow(
"""
SELECT
ai.id, ai.code,
coalesce(ems.fn_inverter_pv_a_max_w(ai.id), 0) AS pv_a_cap_w,
coalesce(ai.deye_reg340_min_solar_w, 0) AS pv_a_reg340_min_w,
se.host, se.port, se.unit_id,
sgc.max_export_power_w,
sgc.max_import_power_w,
sgc.no_export,
ai.max_battery_charge_w,
ai.max_battery_discharge_w,
ab.min_soc_percent,
ab.reserve_soc_percent,
ab.max_soc_percent,
ab.usable_capacity_wh,
ai.deye_last_system_time_sync_minute,
ai.deye_last_system_time_sync_at,
ai.deye_last_tou_inactive_write_prague_date,
ai.deye_tou_inactive_signature,
COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode,
COALESCE(ai.deye_gen_microinverter_cutoff_enabled, false) AS deye_gen_microinverter_cutoff_enabled,
coalesce(ems.fn_site_has_active_green_bonus_pv(ai.site_id), false)
AS deye_reg340_pv_a_control_enabled,
COALESCE(
ai.deye_register_max_charge_a,
FLOOR(
LEAST(
COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w),
ai.max_battery_charge_w
)::numeric / 51.2
)::int
) AS max_charge_a,
COALESCE(
ai.deye_register_max_discharge_a,
FLOOR(
LEAST(
COALESCE(ab.bms_max_discharge_w, ai.max_battery_discharge_w),
ai.max_battery_discharge_w
)::numeric / 51.2
)::int
) AS max_discharge_a
FROM ems.asset_inverter ai
JOIN ems.site_endpoint se ON se.id = ai.endpoint_id
JOIN ems.asset_battery ab ON ab.inverter_id = ai.id
LEFT JOIN ems.site_grid_connection sgc ON sgc.site_id = ai.site_id
WHERE ai.site_id = $1
AND ai.active = true
AND ai.controllable = true
AND se.enabled = true
AND se.endpoint_type = 'modbus_tcp'
ORDER BY ai.id
LIMIT 1
""",
site_id,
)
if row is None:
return None
mc = row["max_charge_a"]
md = row["max_discharge_a"]
max_charge_a = int(mc) if mc is not None else 0
max_discharge_a = int(md) if md is not None else 0
max_charge_a = min(max_charge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
max_discharge_a = min(max_discharge_a, DEYE_LV_BATTERY_MAX_CHARGE_DISCHARGE_A)
port = int(row["port"] or 502)
uid = int(row["unit_id"] if row["unit_id"] is not None else 1)
return InverterConfig(
id=int(row["id"]),
code=row["code"],
host=row["host"],
port=port,
unit_id=uid,
max_export_power_w=int(row["max_export_power_w"])
if row["max_export_power_w"] is not None
else None,
max_import_power_w=int(row["max_import_power_w"])
if row["max_import_power_w"] is not None
else None,
no_export=bool(row["no_export"] or False),
max_battery_charge_w=int(row["max_battery_charge_w"])
if row["max_battery_charge_w"] is not None
else None,
max_battery_discharge_w=int(row["max_battery_discharge_w"])
if row["max_battery_discharge_w"] is not None
else None,
min_soc_percent=int(round(float(row["min_soc_percent"])))
if row["min_soc_percent"] is not None
else None,
reserve_soc_percent=int(row["reserve_soc_percent"])
if row["reserve_soc_percent"] is not None
else None,
max_soc_percent=int(row["max_soc_percent"])
if row["max_soc_percent"] is not None
else None,
usable_capacity_wh=int(row["usable_capacity_wh"])
if row["usable_capacity_wh"] is not None
else None,
max_charge_a=max_charge_a,
max_discharge_a=max_discharge_a,
deye_last_system_time_sync_minute=row["deye_last_system_time_sync_minute"],
deye_last_system_time_sync_at=row["deye_last_system_time_sync_at"],
deye_last_tou_inactive_write_prague_date=row[
"deye_last_tou_inactive_write_prague_date"
],
deye_tou_inactive_signature=row["deye_tou_inactive_signature"],
deye_zero_export_mode=int(row["deye_zero_export_mode"]),
deye_gen_microinverter_cutoff_enabled=bool(
row["deye_gen_microinverter_cutoff_enabled"] or False
),
pv_a_cap_w=int(row["pv_a_cap_w"] or 0),
pv_a_reg340_min_w=int(row["pv_a_reg340_min_w"] or 0),
deye_reg340_pv_a_control_enabled=bool(
row["deye_reg340_pv_a_control_enabled"] or False
),
)
async def _fetch_plan_row_for_slot_offset(
site_id: int, db: asyncpg.Connection, slot_offset: int
) -> asyncpg.Record | None:
"""Řádek plánu pro slot z ems.fn_planning_interval_at_offset (jsonb -> Record-like dict)."""
raw = await db.fetchval(
"""
select ems.fn_planning_interval_at_offset($1::int, $2::int)
""",
site_id,
slot_offset,
)
if raw is None:
return None
data = raw if isinstance(raw, dict) else json.loads(raw)
if not data:
return None
return _DictRecord(data)
async def _fetch_max_charge_power_w(site_id: int, db: asyncpg.Connection) -> int:
v = await db.fetchval(
"select ems.fn_planning_max_effective_charge_w($1::int)",
site_id,
)
return int(v or 0)

View File

@@ -0,0 +1,539 @@
"""Výpočet control setpointů a Deye TOU parametrů."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from services.control.deye_helpers import (
BATT_VOLTAGE_V,
PRAGUE_TZ,
battery_watts_to_amps,
compute_pv_a_reg340_max_solar_w,
watts_to_amps,
)
from services.control.models import ControlSetpoints, InverterConfig, OperatingModeInfo
logger = logging.getLogger(__name__)
#: Tolerance pod max SoC, v rámci níž se v PV přebytku nechá baterka dojet na max
#: (reg 108 = max) kvůli BMS rekalibraci SoC (LiFePO4 potřebuje občas na 100 %).
BATTERY_CALIB_TOPOFF_MARGIN_PCT = 3.0
def _deye_system_time_register_rows() -> tuple[datetime, list[tuple[int, str, int]]]:
"""Hodnoty pro reg 62-64 (Europe/Prague); sekundy v reg 64 = 0 (stabilnější zápis)."""
now = datetime.now(PRAGUE_TZ).replace(second=0, microsecond=0)
reg62 = ((now.year - 2000) << 8) | now.month
reg63 = (now.day << 8) | now.hour
reg64 = (now.minute << 8) | 0
rows = [
(62, "", reg62),
(63, "", reg63),
(64, "", reg64),
]
return now, rows
def _deye_time_point_rows(
slot_index: int,
time_hhmm: int,
power_w: int,
soc_pct: int,
grid_charge: bool,
) -> list[tuple[int, str, int]]:
g = 1 if grid_charge else 0
return [
(148 + slot_index, "", time_hhmm),
(154 + slot_index, "", power_w),
(166 + slot_index, "", soc_pct),
(172 + slot_index, "", g),
]
class _DictRecord:
"""Minimální asyncpg Record kompatibilita pro dict z jsonb."""
__slots__ = ("_d",)
def __init__(self, d: dict[str, Any]) -> None:
self._d = d
def __getitem__(self, k: str) -> Any:
return self._d[k]
def get(self, k: str, default: Any = None) -> Any:
return self._d.get(k, default)
def __contains__(self, k: str) -> bool:
return k in self._d
def plan_skips_deye_reg340_write(
*,
battery_setpoint_w: int,
grid_setpoint_w: int,
export_mode: str | None,
export_limit_w: int,
pv_a_curtailed_w: int,
) -> bool:
"""
Nezapisovat reg 340: plán neexportuje, nenabíjí baterii a neškrtí pole A.
Deye sám řídí PV A přes 108/109/142 (zero export + 0 A nabíjení).
"""
em = (export_mode or "").strip().upper()
if em == "NONE":
no_export = True
elif int(grid_setpoint_w) < 0 or int(export_limit_w) > 0:
no_export = False
else:
no_export = True
return (
no_export
and int(battery_setpoint_w) <= 0
and int(pv_a_curtailed_w) <= 0
)
def _build_setpoints(
mode: OperatingModeInfo,
pi: Any | None,
*,
pv_a_cap_w: int = 0,
pv_a_reg340_min_w: int = 0,
reg340_pv_a_control_enabled: bool = False,
) -> ControlSetpoints | None:
code = mode.mode_code
if code == "MANUAL":
return None
if code == "AUTO":
if pi is None:
return None
grid_sp = int(pi["grid_setpoint_w"] or 0)
export_limit_raw = pi.get("export_limit_w")
export_limit = int(export_limit_raw) if export_limit_raw is not None else abs(min(grid_sp, 0))
ev1_w = int(pi["ev1_setpoint_w"] or 0) if "ev1_setpoint_w" in pi else 0
ev2_w = int(pi["ev2_setpoint_w"] or 0) if "ev2_setpoint_w" in pi else 0
hp_en = bool(pi["heat_pump_enabled"])
tgt = pi["battery_soc_target_pct"]
target_soc = int(round(float(tgt))) if tgt is not None else None
pm_raw = pi.get("deye_physical_mode")
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
export_mode_raw = pi.get("export_mode")
export_mode = str(export_mode_raw).strip().upper() if export_mode_raw is not None else None
if export_mode == "NONE":
export_limit = 0
elif export_limit <= 0 and grid_sp < 0:
export_limit = abs(grid_sp)
bat_w = int(pi["battery_setpoint_w"] or 0)
# Záporný výkup sám o sobě neblokuje export, pokud plán export explicitně žádá.
# A nesmí blokovat ani IMPORT na nabití baterie (CHARGE / grid>0 & bat>0) —
# jinak MI cut-off (178) / 145=0 zbytečně odstaví pole B a Deye nenabije
# ze sítě v záporných cenách (bug 2026-06-13). §6 blokuje jen export.
is_grid_charge = pm == "CHARGE" or (grid_sp > 0 and bat_w > 0)
export_ban = (
sell_f is not None
and float(sell_f) < 0
and grid_sp >= 0
and not is_grid_charge
)
gen_cutoff_raw = pi.get("deye_gen_cutoff_enabled")
gen_cutoff = bool(gen_cutoff_raw) if gen_cutoff_raw is not None else False
pv_a_allowed: int | None = None
if bool(reg340_pv_a_control_enabled) and int(pv_a_cap_w) > 0:
forecast = int(pi.get("pv_a_forecast_solver_w") or 0)
curtail = int(pi.get("pv_a_curtailed_w") or 0)
buy_raw = pi.get("effective_buy_price")
buy_f: float | None = float(buy_raw) if buy_raw is not None else None
pv_b = int(pi.get("pv_b_forecast_solver_w") or 0)
# Záporný buy i sell + pole B: pole A = 0 MÁ PŘEDNOST před úsvitovou
# výjimkou (při hluboce záporných cenách se reg 340 posílá vždy).
_low_pv_no_reg340_w = 1500
if (
buy_f is not None
and sell_f is not None
and float(buy_f) < 0.0
and float(sell_f) < 0.0
and pv_b > 0
):
pv_a_allowed = 0
elif (
# Slabý úsvit: neposílat reg 340 — forecast nepřesný, Deye řídí sám (108/109/142).
forecast < _low_pv_no_reg340_w
and curtail <= 0
and pv_b > 0
):
pv_a_allowed = None
elif plan_skips_deye_reg340_write(
battery_setpoint_w=bat_w,
grid_setpoint_w=grid_sp,
export_mode=export_mode,
export_limit_w=max(0, export_limit),
pv_a_curtailed_w=curtail,
):
pv_a_allowed = None
else:
pv_a_allowed = compute_pv_a_reg340_max_solar_w(
int(pv_a_cap_w),
forecast,
curtail,
min_w=int(pv_a_reg340_min_w),
)
return ControlSetpoints(
battery_w=bat_w,
grid_export_limit=max(0, export_limit),
ev1_current_a=watts_to_amps(ev1_w, phases=3),
ev2_current_a=watts_to_amps(ev2_w, phases=1),
heat_pump_enable=hp_en,
grid_setpoint_w=grid_sp,
ev1_power_w=ev1_w,
ev2_power_w=ev2_w,
target_soc_pct=target_soc,
deye_physical_mode=pm,
export_mode=export_mode,
export_ban=bool(export_ban),
deye_gen_cutoff_enabled=bool(gen_cutoff),
effective_sell_price_czk_kwh=sell_f,
pv_a_allowed_w=pv_a_allowed,
)
if code == "SELF_SUSTAIN":
return ControlSetpoints(
battery_w=None,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
self_sustain_local_use=True,
)
if code == "CHARGE_CHEAP":
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
)
if code == "PRESERVE":
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,
lock_battery=True,
)
logger.warning("Unknown mode_code %s for site export, skipping", code)
return None
def _passive_no_export_guard(
sp: ControlSetpoints, *, hard_ban: bool = True
) -> ControlSetpoints:
"""
PASSIVE, žádný vývoz do sítě z plánu (143=0, grid_setpoint>=0, baterie nevybíjí do sítě).
``hard_ban=True`` (záporná vykupní): navíc export_ban (145=0) a MI cut-off na GEN
portu (reg 178) — přebytek pole B NESMÍ do sítě.
``hard_ban=False`` (kladná vykupní, plán jen nechce exportovat baterii/stringy):
mikroinvertory NEodstavovat — jejich výroba se absorbuje do baterie/zátěže a
případný fyzický přetok se při kladné ceně prodá (cut-off by výrobu zahodil).
"""
bat = int(sp.battery_w or 0)
if bat < 0:
bat = 0
return ControlSetpoints(
battery_w=bat,
grid_export_limit=0,
ev1_current_a=sp.ev1_current_a,
ev2_current_a=sp.ev2_current_a,
heat_pump_enable=sp.heat_pump_enable,
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
ev1_power_w=sp.ev1_power_w,
ev2_power_w=sp.ev2_power_w,
target_soc_pct=sp.target_soc_pct,
deye_physical_mode="PASSIVE",
export_mode="NONE",
export_ban=bool(sp.export_ban) or hard_ban,
deye_gen_cutoff_enabled=bool(sp.deye_gen_cutoff_enabled) or hard_ban,
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
pv_a_allowed_w=sp.pv_a_allowed_w,
lock_battery=sp.lock_battery,
self_sustain_local_use=sp.self_sustain_local_use,
)
def _apply_export_plan_guard(
site_id: int,
mode: OperatingModeInfo,
pi: Any | None,
sp: ControlSetpoints,
) -> ControlSetpoints:
"""
Exekuční pojistka: plán zakazuje vývoz (záporná vykupní nebo export_mode NONE),
ale Deye může zůstat v SELL — vynutit PASSIVE a export_ban před zápisem Modbus.
"""
if mode.mode_code != "AUTO" or pi is None:
return sp
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = (
float(sell_raw) if sell_raw is not None else sp.effective_sell_price_czk_kwh
)
export_mode_raw = pi.get("export_mode")
export_mode = (
str(export_mode_raw).strip().upper()
if export_mode_raw is not None
else (sp.export_mode or "")
)
grid_sp = int(pi.get("grid_setpoint_w") or sp.grid_setpoint_w or 0)
# Carve-out: nabíjecí / importní slot NENÍ export. Guard řeší jen zákaz
# exportu při sell<0 — když plán importuje na nabití baterie (CHARGE, nebo
# grid_sp>0 & bat_sp>0), překlopení na PASSIVE by zařízlo grid charge
# (bug 2026-06-13: baterie se nedobila v záporných cenách). §6 zakazuje
# jen export, ne import (§7).
pm = str(pi.get("deye_physical_mode") or "").strip().upper()
bat_sp = int(pi.get("battery_setpoint_w") or 0)
if pm == "CHARGE" or (grid_sp > 0 and bat_sp > 0):
return sp
neg_sell = sell_f is not None and float(sell_f) < 0
plan_no_export = export_mode == "NONE" and grid_sp >= 0
if not neg_sell and not plan_no_export:
return sp
reason = "neg_sell" if neg_sell else "export_mode_none"
logger.warning(
"control export site=%s: AUTO export plan guard (%s) -> PASSIVE no-export",
site_id,
reason,
)
# MI cut-off / 145=0 jen při záporné vykupní; export_mode NONE s kladnou cenou
# nesmí odstavit pole B (BA81 2026-06-12: cutoff při sell +1.36 → výroba MI zahozena).
return _passive_no_export_guard(sp, hard_ban=neg_sell)
def _apply_price_failsafe_guard(
site_id: int,
mode: OperatingModeInfo,
pi: Any | None,
sp: ControlSetpoints,
) -> ControlSetpoints:
if mode.mode_code != "AUTO" or pi is None:
return sp
if "is_predicted_price" not in pi or not bool(pi["is_predicted_price"]):
return sp
logger.warning(
"control export site=%s: AUTO slot uses predicted price -> forcing PASSIVE no-export guard",
site_id,
)
return ControlSetpoints(
battery_w=0,
grid_export_limit=0,
ev1_current_a=sp.ev1_current_a,
ev2_current_a=sp.ev2_current_a,
heat_pump_enable=sp.heat_pump_enable,
grid_setpoint_w=max(0, int(sp.grid_setpoint_w or 0)),
ev1_power_w=sp.ev1_power_w,
ev2_power_w=sp.ev2_power_w,
target_soc_pct=sp.target_soc_pct,
effective_sell_price_czk_kwh=sp.effective_sell_price_czk_kwh,
pv_a_allowed_w=sp.pv_a_allowed_w,
)
def _deye_reg143_export_w(no_export: bool, max_export_power_w: int | None) -> int:
"""Reg 143 - max export W z DB (např. SUN-20K / home-01 = 13 500 W)."""
if no_export:
return 0
return max(0, int(max_export_power_w or 0))
def _is_passive_pv_surplus_export(
*,
deye_mode: str,
export_mode: str | None,
export_ban: bool,
grid_w: int,
) -> bool:
"""
Přetok FVE do sítě v PASSIVE (ne SELL z baterie): reg 142 zůstane zero-export (1/2),
nabíjení se blokuje přes **108 = 0** — baterie nemá kam brát přebytek → jde do sítě (145).
"""
if deye_mode != "PASSIVE" or export_ban:
return False
em = (export_mode or "").strip().upper()
if em == "PV_SURPLUS":
return True
if em in {"NONE", "BATTERY_SELL"}:
return False
return grid_w < 0
def _clamp_deye_tou_soc_pct(pct: int) -> int:
return max(5, min(95, pct))
def _deye_tou_min_soc_pct(inv: InverterConfig) -> int:
if inv.min_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.min_soc_percent))
return 10
def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int:
if inv.reserve_soc_percent is not None:
return _clamp_deye_tou_soc_pct(int(inv.reserve_soc_percent))
return 20
def _deye_passive_tou_battery_soc_pct(
inv: InverterConfig, _setpoints: ControlSetpoints
) -> int:
"""Hodnota SOC u Deye TOU řádku (reg 166+) ve fyzickém PASSIVE."""
return _deye_tou_min_soc_pct(inv)
def _deye_zero_export_amps_for_passive(
grid_w: int,
bat_w: int,
max_charge_a: int,
max_discharge_a: int,
) -> tuple[int, int]:
"""
PASSIVE (zero export k CT/zátěži): asymetrie jen tam, kde dává smysl pro import.
Přetok FVE do sítě řeší větev ``_is_passive_pv_surplus_export`` (**108 = 0**). Zde jen import
bez nabíjení → vypnout vybíjení (**109 = 0**).
"""
if grid_w > 0 and bat_w <= 0:
return max_charge_a, 0
return int(max_charge_a), int(max_discharge_a)
def deye_battery_charge_discharge_amps(
*,
lock_battery: bool,
deye_mode: str,
self_sustain_local_use: bool,
bat_w: int,
grid_w: int,
max_charge_a: int,
max_discharge_a: int,
export_mode: str | None = None,
export_ban: bool = False,
current_soc_pct: float | None = None,
max_soc_pct: int | None = None,
) -> tuple[int | None, int]:
"""
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
**PV_SURPLUS** (PASSIVE, export FVE) — reg 108 SLEDUJE charge intent plánu (fix 2026-06-16):
- `bat_w > 0` (plán chce nabíjet z přebytku) → **108 = max**: baterie nabere kolik fyzicky
zvládne (nabíjecí rychlost), přebytek NAD ni jde do sítě (BA81: výroba 12 kW > rychlost
6 kW → 6 do baterky, 6 ven). Dřív tvrdě 108=0 i při bat_w>0 → baterka nenabíjela ani
levné ranní PV (control bug).
- kalibrace: SoC u maxima (`>= max_soc margin`) + přebytek → **108 = max**, ať dojede na
100 % (BMS rekalibrace SoC). Strop drží Deye max_soc.
- jen „prodej PV a drž baterku" daleko od maxima (`bat_w <= 0`) → **108 = 0**, přebytek ven.
PASSIVE + nabíjení bez exportního záměru (`battery_w > 0`, export_mode NONE): **108 = max**.
**CHARGE** ze sítě: 108 z `battery_w`.
**SELL** (selling first, reg 142 = 0): vrací ``(None, max_discharge)`` — reg **108 se nezapisuje**
(export řídí 142/178; nulování 108 a obnova po návratu jsou zbytečné zápisy do paměti).
"""
if lock_battery:
return 0, 0
if deye_mode == "CHARGE":
return battery_watts_to_amps(bat_w, max_charge_a), 0
if deye_mode == "SELL":
return None, int(max_discharge_a)
if self_sustain_local_use:
return int(max_charge_a), int(max_discharge_a)
if _is_passive_pv_surplus_export(
deye_mode=deye_mode,
export_mode=export_mode,
export_ban=export_ban,
grid_w=grid_w,
):
# reg 108 sleduje charge intent: nabíjet z přebytku (bat_w>0) nebo dojet na max
# kvůli BMS kalibraci (SoC u maxima + přebytek) → 108 = max; jinak 108 = 0 (přebytek
# ven). Strop SoC drží Deye max_soc, takže 108=max nepřebije nad povolené.
near_full_calib = (
current_soc_pct is not None
and max_soc_pct is not None
and float(current_soc_pct) >= float(max_soc_pct) - BATTERY_CALIB_TOPOFF_MARGIN_PCT
)
if bat_w > 0 or near_full_calib:
return int(max_charge_a), int(max_discharge_a)
return 0, int(max_discharge_a)
if bat_w > 0:
return int(max_charge_a), int(max_discharge_a)
return _deye_zero_export_amps_for_passive(
grid_w, bat_w, int(max_charge_a), int(max_discharge_a)
)
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""Fyzický režim Deye: SELL | CHARGE | PASSIVE."""
pm = (setpoints.deye_physical_mode or "").strip().upper()
if pm in {"PASSIVE", "SELL", "CHARGE"}:
return pm
grid_w = int(setpoints.grid_setpoint_w or 0)
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
if bat_w > 0 and grid_w > 0:
return "CHARGE"
if grid_w < 0 and bat_w < 0:
return "SELL"
return "PASSIVE"
def _deye_tou_params(
setpoints: ControlSetpoints,
inv: InverterConfig,
) -> tuple[int, int, bool]:
"""Parametry jednoho Deye time pointu: výkon W, SOC % (TOU reg 166+), grid_charge."""
max_batt_w_discharge = int(inv.max_discharge_a * BATT_VOLTAGE_V)
tp_discharge_w = 0 if setpoints.lock_battery else max_batt_w_discharge
tou_min = _deye_tou_min_soc_pct(inv)
tou_reserve = _deye_tou_reserve_soc_pct(inv)
if setpoints.lock_battery:
return tp_discharge_w, tou_min, False
deye_mode = get_deye_mode(setpoints)
if deye_mode == "CHARGE":
raw_bat = setpoints.battery_w
battery_w = int(raw_bat) if raw_bat is not None else 0
cap = int(inv.max_soc_percent) if inv.max_soc_percent is not None else 95
target_soc = max(10, min(100, cap))
tp_charge_w = (
battery_watts_to_amps(battery_w, int(inv.max_charge_a)) * int(BATT_VOLTAGE_V)
)
return tp_charge_w, target_soc, True
if deye_mode == "SELL":
return tp_discharge_w, tou_reserve, False
tou_soc = _deye_passive_tou_battery_soc_pct(inv, setpoints)
return tp_discharge_w, tou_soc, False

View File

@@ -0,0 +1,481 @@
"""Modbus verify workflow pro control export."""
from __future__ import annotations
import logging
from collections import defaultdict
from typing import Any
import asyncpg
from services.control.deye_helpers import (
DEYE_CLOCK_REGS,
DEYE_TOU_POWER_REGS,
REG178_VERIFY_MASK,
_deye_clock_registers_verify_match,
_deye_reg178_verify_match,
_deye_reg178_verify_with_double_read,
_deye_tou_power_verify_match,
_prague_minute_start_utc,
deye_reg_triggers_self_sustain_after_verify_exhaust,
)
from services.control.modbus_journal import (
_fetch_last_verified_inverter_registers,
_fetch_written_deye_clock_commands,
_modbus_command_contiguous_runs,
execute_modbus_commands,
)
from services.control.repository import _load_inverter_config
from services.modbus_client import get_modbus_client
logger = logging.getLogger(__name__)
async def _switch_to_self_sustain(site_id: int, db: asyncpg.Connection, reason: str) -> None:
"""Přepne lokalitu na SELF_SUSTAIN, zaloguje důvod a při změně pošle Discord."""
from services.notification_service import run_fn_set_mode_with_discord
await run_fn_set_mode_with_discord(
db,
site_id,
"SELF_SUSTAIN",
"system:mismatch",
None,
reason,
)
logger.critical("Site %s switched to SELF_SUSTAIN: %s", site_id, reason)
def _modbus_cmd_register(cmd: Any) -> int:
"""asyncpg.Record má __getitem__; objekty s atributem .register též (testy)."""
try:
return int(cmd["register"])
except (KeyError, TypeError):
return int(cmd.register)
def _deye_expected_clock_triplet_for_verify(
bundle: list[asyncpg.Record],
last_verified: dict[int, int],
a62: int,
a63: int,
a64: int,
) -> tuple[int, int, int]:
"""
Sestaví očekávané (w62,w63,w64) pro toleranční verify.
Chybějící registry doplní poslední verified nebo aktuálním přečtením ze zařízení.
"""
by_reg = {_modbus_cmd_register(c): c for c in bundle}
def _vtw(c: Any) -> int:
try:
return int(c["value_to_write"])
except (KeyError, TypeError):
return int(c.value_to_write)
w62 = _vtw(by_reg[62]) if 62 in by_reg else last_verified.get(62, a62)
w63 = _vtw(by_reg[63]) if 63 in by_reg else last_verified.get(63, a63)
w64 = _vtw(by_reg[64]) if 64 in by_reg else last_verified.get(64, a64)
return (int(w62), int(w63), int(w64))
async def _verify_deye_clock_written_bundle(
site_id: int,
bundle: list[asyncpg.Record],
a62: int,
a63: int,
a64: int,
db: asyncpg.Connection,
) -> bool:
"""
Toleranční ověření pro jeden až tři řádky journalu 62-64 ve stavu written.
Při mismatch retry společně; bez přepnutí do SELF_SUSTAIN po 3 pokusech.
"""
from services.notification_service import (
notify_modbus_clock_verify_exhausted,
notify_modbus_mismatch,
)
cmds_s = sorted(bundle, key=_modbus_cmd_register)
try:
asset_id = int(cmds_s[0]["asset_id"])
except (KeyError, TypeError):
asset_id = int(cmds_s[0].asset_id)
last_v = await _fetch_last_verified_inverter_registers(site_id, asset_id, db)
w62, w63, w64 = _deye_expected_clock_triplet_for_verify(bundle, last_v, a62, a63, a64)
clock_ok = _deye_clock_registers_verify_match(w62, w63, w64, a62, a63, a64)
actual_by_reg = {62: a62, 63: a63, 64: a64}
for cmd in cmds_s:
try:
cid = int(cmd["id"])
except (KeyError, TypeError):
cid = int(cmd.id)
r = _modbus_cmd_register(cmd)
await db.execute(
"""
UPDATE ems.modbus_command
SET value_verified=$1::int, verified_at=now(),
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
WHERE id=$3::int
""",
actual_by_reg[r],
clock_ok,
cid,
)
if clock_ok:
await db.execute(
"""
UPDATE ems.asset_inverter
SET deye_last_system_time_sync_minute = $1,
deye_last_system_time_sync_at = now()
WHERE id = $2
""",
_prague_minute_start_utc(),
asset_id,
)
for cmd in cmds_s:
try:
cid_l = int(cmd["id"])
except (KeyError, TypeError):
cid_l = int(cmd.id)
try:
code_l = str(cmd["asset_code"])
except (KeyError, TypeError):
code_l = str(cmd.asset_code)
rr = _modbus_cmd_register(cmd)
logger.info(
"[cmd %s] verified OK (clock tolerant): %s 0x%04X=%s",
cid_l,
code_l,
rr,
actual_by_reg[rr],
)
return True
cmd0 = cmds_s[0]
try:
ac0 = str(cmd0["asset_code"])
except (KeyError, TypeError):
ac0 = str(cmd0.asset_code)
logger.error(
"[cmd clock] MISMATCH %s 62-64: written=(%s,%s,%s) actual=(%s,%s,%s)",
ac0,
w62,
w63,
w64,
a62,
a63,
a64,
)
attempts = 0
for cmd in cmds_s:
try:
cid_q = int(cmd["id"])
except (KeyError, TypeError):
cid_q = int(cmd.id)
row_ac = await db.fetchrow(
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cid_q
)
ac = int(row_ac["attempt_count"] or 0) if row_ac else 0
attempts = max(attempts, ac)
await notify_modbus_mismatch(
db,
site_id,
ac0,
62,
"system_time_62_64",
w62,
a62,
attempts,
)
ids_ordered = []
for c in cmds_s:
try:
ids_ordered.append(int(c["id"]))
except (KeyError, TypeError):
ids_ordered.append(int(c.id))
if attempts < 3:
for cid in ids_ordered:
await db.execute(
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
cid,
)
await execute_modbus_commands(ids_ordered, db)
await verify_modbus_commands(ids_ordered, db, site_id)
else:
logger.critical(
"[cmd clock] 3 failed verify attempts (62-64); režim se nemění automaticky"
)
site = await db.fetchrow("SELECT code FROM ems.site WHERE id=$1", site_id)
await notify_modbus_clock_verify_exhausted(
db,
site_id,
site["code"] if site else str(site_id),
ac0,
(w62, w63, w64),
(a62, a63, a64),
)
return False
async def verify_modbus_commands(
command_ids: list[int],
db: asyncpg.Connection,
site_id: int,
) -> bool:
"""
Přečte registry zpět (FC 3 po souvislých blocích) a porovná s value_to_write.
Při mismatch řeší retry a po vyčerpání kritických registrů SELF_SUSTAIN.
"""
from services.notification_service import notify_modbus_mismatch
inv_cfg = await _load_inverter_config(site_id, db)
async def _apply_verify_result(
cmd: asyncpg.Record,
actual_i: int,
*,
client: Any,
unit: int,
) -> bool:
reg = int(cmd["register"])
cmd_id = int(cmd["id"])
if reg in DEYE_CLOCK_REGS:
asset_id = int(cmd["asset_id"])
host = str(cmd["device_host"])
port_i = int(cmd["device_port"])
uid = int(cmd["device_unit_id"])
bundle = await _fetch_written_deye_clock_commands(
site_id, asset_id, host, port_i, uid, db
)
if not bundle:
bundle = [cmd]
try:
cvals = await client.read_holding_registers(62, 3, uid)
except Exception as e:
logger.error(
"verify clock guard read 62-64 failed (reg 0x%04X): %s", reg, e
)
return False
if len(cvals) != 3:
logger.error("verify clock guard: expected 3 regs, got %s", len(cvals))
return False
logger.warning(
"Clock register 0x%04X reached strict verify path; using tolerant 62-64 bundle",
reg,
)
return await _verify_deye_clock_written_bundle(
site_id,
bundle,
int(cvals[0]),
int(cvals[1]),
int(cvals[2]),
db,
)
expected_i = int(cmd["value_to_write"])
matches = actual_i == expected_i
if reg == 178:
first_178 = int(actual_i)
second_178: int | None = None
if not _deye_reg178_verify_match(expected_i, first_178):
try:
r178 = await client.read_holding_registers(178, 1, unit)
if r178 and len(r178) >= 1:
second_178 = int(r178[0])
except Exception as e:
logger.warning("[cmd %s] reg178 double-read failed: %s", cmd_id, e)
matches, actual_i = _deye_reg178_verify_with_double_read(
expected_i, first_178, second_178
)
if (
matches
and second_178 is not None
and not _deye_reg178_verify_match(expected_i, first_178)
):
logger.info(
"[cmd %s] reg178 double-read recovered: first=%s second=%s",
cmd_id,
first_178,
second_178,
)
if not matches and reg in DEYE_TOU_POWER_REGS and inv_cfg is not None:
matches = _deye_tou_power_verify_match(expected_i, actual_i, inv_cfg)
await db.execute(
"""
UPDATE ems.modbus_command
SET value_verified=$1::int, verified_at=now(),
status=CASE WHEN $2::boolean THEN 'verified' ELSE 'mismatch' END
WHERE id=$3::int
""",
actual_i,
matches,
cmd_id,
)
if not matches:
logger.error(
"[cmd %s] MISMATCH %s 0x%04X: expected=%s actual=%s%s",
cmd_id,
cmd["asset_code"],
reg,
expected_i,
actual_i,
" (reg178 mask 0x%04X)" % REG178_VERIFY_MASK if reg == 178 else "",
)
row_ac = await db.fetchrow(
"SELECT attempt_count FROM ems.modbus_command WHERE id=$1", cmd_id
)
attempts = int(row_ac["attempt_count"] or 0) if row_ac else 0
await notify_modbus_mismatch(
db,
site_id,
cmd["asset_code"],
reg,
cmd["register_name"] or "",
expected_i,
actual_i,
attempts,
)
if attempts < 3:
await db.execute(
"UPDATE ems.modbus_command SET status='retrying' WHERE id=$1",
cmd_id,
)
await execute_modbus_commands([cmd_id], db)
await verify_modbus_commands([cmd_id], db, site_id)
else:
# SELF_SUSTAIN fallback je Deye politika — mismatch na jiném
# zařízení (EV wallbox…) nesmí degradovat režim celé lokality.
if (
str(cmd["asset_type"]) == "inverter"
and deye_reg_triggers_self_sustain_after_verify_exhaust(reg)
):
logger.critical(
"[cmd %s] 3 failed attempts, switching to SELF_SUSTAIN",
cmd_id,
)
await _switch_to_self_sustain(
site_id,
db,
reason=(
f"Modbus mismatch po 3 pokusech: {cmd['asset_code']} "
f"reg 0x{reg:04X}"
),
)
else:
logger.warning(
"[cmd %s] 3 failed verify attempts on non-critical reg 0x%04X "
"(no mode change): %s",
cmd_id,
reg,
cmd["asset_code"],
)
return False
if reg == 178 and actual_i != expected_i:
logger.info(
"[cmd %s] verified OK (reg178 masked): %s 0x%04X value_to_write=%s actual=%s",
cmd_id,
cmd["asset_code"],
reg,
expected_i,
actual_i,
)
else:
logger.info(
"[cmd %s] verified OK: %s 0x%04X=%s",
cmd_id,
cmd["asset_code"],
reg,
actual_i,
)
return True
cmds: list[asyncpg.Record] = []
for cmd_id in command_ids:
cmd = await db.fetchrow(
"SELECT * FROM ems.modbus_command WHERE id=$1", cmd_id
)
if cmd is not None and cmd["status"] == "written":
cmds.append(cmd)
if not cmds:
return True
by_gw: dict[tuple[str, int, int], list[asyncpg.Record]] = defaultdict(list)
for cmd in cmds:
by_gw[
(cmd["device_host"], int(cmd["device_port"]), int(cmd["device_unit_id"]))
].append(cmd)
all_ok = True
for (host, port, unit), group in by_gw.items():
client = await get_modbus_client(host, port)
clock_cmds = [c for c in group if int(c["register"]) in DEYE_CLOCK_REGS]
rest = [c for c in group if int(c["register"]) not in DEYE_CLOCK_REGS]
if clock_cmds:
asset_id = int(clock_cmds[0]["asset_id"])
bundle = await _fetch_written_deye_clock_commands(
site_id, asset_id, host, port, unit, db
)
if not bundle:
bundle = clock_cmds
try:
cvals = await client.read_holding_registers(62, 3, unit)
except Exception as e:
logger.error("verify clock read 62-64 failed: %s", e)
all_ok = False
else:
if len(cvals) != 3:
logger.error("verify clock read: expected 3 regs, got %s", len(cvals))
all_ok = False
else:
matched = await _verify_deye_clock_written_bundle(
site_id,
bundle,
int(cvals[0]),
int(cvals[1]),
int(cvals[2]),
db,
)
if not matched:
all_ok = False
for run in _modbus_command_contiguous_runs(rest):
start_reg = int(run[0]["register"])
n = len(run)
try:
values = await client.read_holding_registers(start_reg, n, unit)
except Exception as e:
logger.error(
"verify batch read 0x%04X count=%s failed: %s", start_reg, n, e
)
all_ok = False
continue
if len(values) != n:
logger.error(
"verify read 0x%04X: expected %s regs, got %s",
start_reg,
n,
len(values),
)
all_ok = False
continue
for cmd, actual in zip(run, values):
matched = await _apply_verify_result(
cmd, int(actual), client=client, unit=unit
)
if not matched:
all_ok = False
return all_ok

View File

@@ -0,0 +1,367 @@
"""Discord bot (gateway) — interaktivní EV zprávy se dvěma výběry.
Architektura: websocket spojení jde Z BACKENDU VEN (žádný veřejný endpoint,
EMS zůstává na VPN). Bot reaguje výhradně na whitelisted user ID a jediné,
co umí, je patch otevřené EV session + okamžitý replan — žádné režimy,
žádné registry. Bez DISCORD_BOT_TOKEN modul tiše spí (fáze A webhook).
UI: dva persistent Selecty (custom_id template, takže fungují i po
restartu backendu — obsluha jde přes on_interaction + regex, ne přes
zaregistrovanou View instanci):
ev:<site_id>:<charger_code>:dep — „Kdy odjíždíš?"
za 2 h | za 4 h | dnes večer 18:00 | zítra ráno 7:00 |
zítra poledne 12:00 | pondělí ráno 7:00
ev:<site_id>:<charger_code>:tgt — „Kolik potřebuješ?"
30 % | 50 % | 70 % | 100 % | Nenabíjet (target = SoC při připojení)
Každý výběr okamžitě PATCHne session (fn_ev_session_apply_patch) jen v dané
dimenzi — druhý rozměr zůstává (default z ems.fn_ev_session_defaults nebo
předchozí výběr). Legacy tlačítka h2/h4/morning/full/stop ze starších zpráv
zůstávají obsloužená (action_to_patch).
Postup zřízení bota: docs/discord-ev-interaction.md.
"""
from __future__ import annotations
import asyncio
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
import asyncpg
from app.config import get_settings
logger = logging.getLogger(__name__)
_PRAGUE = ZoneInfo("Europe/Prague")
_POOL: asyncpg.Pool | None = None
_CLIENT: Any = None # discord.Client za lazy importem
CUSTOM_ID_RE = re.compile(
r"^ev:(?P<site>\d+):(?P<charger>[a-z0-9\-]+)"
r":(?P<action>dep|tgt|h2|h4|morning|full|stop)$"
)
#: Výběr 1 — „Kdy odjíždíš?" (value, label); absolutní čas viz
#: departure_choice_to_deadline (Europe/Prague).
DEP_CHOICES: list[tuple[str, str]] = [
("h2", "za 2 h"),
("h4", "za 4 h"),
("today18", "dnes večer 18:00"),
("tomorrow7", "zítra ráno 7:00"),
("tomorrow12", "zítra poledne 12:00"),
("monday7", "pondělí ráno 7:00"),
]
#: Výběr 2 — „Kolik potřebuješ?" (value, label); "stop" = nenabíjet.
TGT_CHOICES: list[tuple[str, str]] = [
("30", "30 %"),
("50", "50 %"),
("70", "70 %"),
("100", "100 %"),
("stop", "Nenabíjet"),
]
#: Legacy tlačítka (starší zprávy poslané před přechodem na selecty).
LEGACY_ACTION_LABELS = {
"h2": "za 2 h",
"h4": "za 4 h",
"morning": "ráno",
"full": "do plna",
"stop": "nenabíjet",
}
def parse_custom_id(cid: str) -> tuple[int, str, str] | None:
m = CUSTOM_ID_RE.match(cid or "")
if not m:
return None
return int(m.group("site")), m.group("charger"), m.group("action")
def departure_choice_to_deadline(choice: str, *, now: datetime) -> datetime:
"""Absolutní deadline (Europe/Prague) pro volbu z výběru „Kdy odjíždíš?".
Čisté a testovatelné. Volby s pevným časem znamenají NEJBLIŽŠÍ budoucí
výskyt (dnes 18:00 po 18. hodině → zítra 18:00; pondělí 7:00 z pátku je
explicitní volba — smí být i za >48 h).
"""
local = now.astimezone(_PRAGUE)
if choice == "h2":
return local + timedelta(hours=2)
if choice == "h4":
return local + timedelta(hours=4)
if choice == "today18":
candidate = local.replace(hour=18, minute=0, second=0, microsecond=0)
if candidate <= local:
candidate += timedelta(days=1)
return candidate
if choice == "tomorrow7":
return (local + timedelta(days=1)).replace(
hour=7, minute=0, second=0, microsecond=0
)
if choice == "tomorrow12":
return (local + timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
if choice == "monday7":
candidate = local.replace(hour=7, minute=0, second=0, microsecond=0)
candidate += timedelta(days=(0 - local.weekday()) % 7) # 0 = pondělí
if candidate <= local:
candidate += timedelta(days=7)
return candidate
raise ValueError(f"unknown departure choice {choice}")
def select_to_patch(
kind: str,
value: str,
*,
now: datetime,
soc_at_connect: float | None,
) -> dict:
"""Patch pro fn_ev_session_apply_patch z hodnoty selectu (čisté, testovatelné).
Patchuje VŽDY jen jednu dimenzi — druhá zůstává beze změny
(default z fn_ev_session_defaults, případně dřívější výběr).
"""
if kind == "dep":
deadline = departure_choice_to_deadline(value, now=now)
return {"target_deadline": deadline.isoformat()}
if kind == "tgt":
if value == "stop":
return {"target_soc_pct": float(soc_at_connect or 0)}
return {"target_soc_pct": float(value)}
raise ValueError(f"unknown select kind {kind}")
def choice_label(kind: str, value: str) -> str:
"""Lidský popisek volby pro potvrzení ve zprávě."""
if kind == "dep":
return "odjezd " + dict(DEP_CHOICES).get(value, value)
if kind == "tgt":
if value == "stop":
return "nenabíjet"
return "cíl " + dict(TGT_CHOICES).get(value, value)
return LEGACY_ACTION_LABELS.get(value, value)
def action_to_patch(
action: str,
*,
now: datetime,
soc_at_connect: float | None,
default_deadline_hour: int | None,
) -> dict:
"""Patch pro legacy tlačítka h2/h4/morning/full/stop (starší zprávy)."""
if action == "h2":
return {"target_deadline": (now + timedelta(hours=2)).isoformat()}
if action == "h4":
return {"target_deadline": (now + timedelta(hours=4)).isoformat()}
if action == "morning":
hour = int(default_deadline_hour or 7)
local = now.astimezone(_PRAGUE)
candidate = local.replace(hour=hour, minute=0, second=0, microsecond=0)
if candidate <= local:
candidate += timedelta(days=1)
return {"target_deadline": candidate.isoformat()}
if action == "full":
return {
"target_soc_pct": 100,
"target_deadline": (now + timedelta(hours=1)).isoformat(),
}
if action == "stop":
return {"target_soc_pct": float(soc_at_connect or 0)}
raise ValueError(f"unknown action {action}")
def set_pool(pool: asyncpg.Pool) -> None:
global _POOL
_POOL = pool
def _allowed_user_ids() -> set[int]:
raw = (getattr(get_settings(), "discord_allowed_user_ids", "") or "").strip()
out: set[int] = set()
for part in raw.split(","):
part = part.strip()
if part.isdigit():
out.add(int(part))
return out
def _build_view(site_id: int, charger_code: str):
"""View se dvěma selecty (persistent custom_id, timeout=None)."""
import discord
view = discord.ui.View(timeout=None)
view.add_item(
discord.ui.Select(
custom_id=f"ev:{site_id}:{charger_code}:dep",
placeholder="🕑 Kdy odjíždíš?",
min_values=1,
max_values=1,
options=[
discord.SelectOption(label=label, value=value)
for value, label in DEP_CHOICES
],
)
)
view.add_item(
discord.ui.Select(
custom_id=f"ev:{site_id}:{charger_code}:tgt",
placeholder="🔋 Kolik potřebuješ?",
min_values=1,
max_values=1,
options=[
discord.SelectOption(label=label, value=value)
for value, label in TGT_CHOICES
],
)
)
return view
async def post_ev_arrival(
site_id: int, charger_code: str, session_id: int, text: str
) -> bool:
"""Pošle zprávu s výběry přes bota. False = bot neběží/není kanál (fallback webhook)."""
if _CLIENT is None or not _CLIENT.is_ready():
return False
channel_id = int(getattr(get_settings(), "discord_ev_channel_id", 0) or 0)
if not channel_id:
return False
channel = _CLIENT.get_channel(channel_id)
if channel is None:
return False
await channel.send(content=text, view=_build_view(site_id, charger_code))
return True
async def _handle_action(
interaction: Any,
site_id: int,
charger_code: str,
action: str,
value: str | None,
) -> None:
import json
from services.control_exporter import export_setpoints
from services.ev_notify import build_ev_plan_summary, get_open_session
from services.planning_engine import run_rolling_replan
assert _POOL is not None
async with _POOL.acquire() as conn:
sess = await get_open_session(site_id, charger_code, conn)
if sess is None:
await interaction.followup.send(
"Session už není otevřená (auto odpojeno?).", ephemeral=True
)
return
now = datetime.now(timezone.utc)
if action in ("dep", "tgt"):
if not value:
return
patch = select_to_patch(
action,
value,
now=now,
soc_at_connect=sess["soc_at_connect_pct"],
)
label = choice_label(action, value)
else: # legacy tlačítka starších zpráv
patch = action_to_patch(
action,
now=now,
soc_at_connect=sess["soc_at_connect_pct"],
default_deadline_hour=sess["default_deadline_hour"],
)
label = LEGACY_ACTION_LABELS.get(action, action)
await conn.fetchval(
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
site_id,
int(sess["session_id"]),
json.dumps(patch),
)
await run_rolling_replan(
site_id, conn, triggered_by=f"discord:{action}:{charger_code}"
)
await export_setpoints(site_id, conn)
new_text = await build_ev_plan_summary(site_id, charger_code, conn)
if new_text:
await interaction.message.edit(
content=new_text + f"\n_(nastaveno: {label})_",
view=_build_view(site_id, charger_code),
)
await interaction.followup.send(f"Přeplánováno ✓ ({label})", ephemeral=True)
async def run_discord_bot() -> None:
"""Lifespan task: připojí gateway a obsluhuje selecty/tlačítka. Bez tokenu hned končí."""
token = (getattr(get_settings(), "discord_bot_token", "") or "").strip()
if not token:
logger.info("Discord bot: token není nastaven — fáze B vypnuta")
return
import discord
intents = discord.Intents.default()
client = discord.Client(intents=intents)
@client.event
async def on_ready() -> None:
logger.info("Discord bot připojen jako %s", client.user)
try:
channel_id = int(getattr(get_settings(), "discord_ev_channel_id", 0) or 0)
ch = client.get_channel(channel_id) if channel_id else None
if ch is not None:
await ch.send("✅ EMS bot online — notifikace aktivní")
except Exception:
logger.exception("Discord on_ready ping failed")
@client.event
async def on_interaction(interaction: discord.Interaction) -> None:
if interaction.type != discord.InteractionType.component:
return
data = interaction.data or {}
cid = data.get("custom_id", "")
parsed = parse_custom_id(str(cid))
if parsed is None:
return
allowed = _allowed_user_ids()
if allowed and interaction.user.id not in allowed:
await interaction.response.send_message(
"Tenhle výběr není pro tebe. 🙂", ephemeral=True
)
return
await interaction.response.defer(ephemeral=True, thinking=True)
site_id, charger_code, action = parsed
values = data.get("values") or []
value = str(values[0]) if values else None
try:
await _handle_action(interaction, site_id, charger_code, action, value)
except Exception:
logger.exception("Discord akce selhala (%s, value=%s)", cid, value)
try:
await interaction.followup.send(
"Akce selhala — mrkni do logů.", ephemeral=True
)
except Exception:
pass
global _CLIENT
_CLIENT = client
try:
await client.start(token)
except asyncio.CancelledError:
await client.close()
raise
except Exception:
logger.exception("Discord bot spadl — fáze B mimo provoz (fallback webhook)")
finally:
_CLIENT = None

View File

@@ -0,0 +1,115 @@
"""Souhrn EV nabíjecího plánu pro notifikace (Discord webhook i bot).
Sdílené mezi telemetry_collector (zpráva po příjezdu) a discord_bot
(přestavba zprávy po akci tlačítkem).
"""
from __future__ import annotations
import logging
from zoneinfo import ZoneInfo
import asyncpg
logger = logging.getLogger(__name__)
_PRAGUE = ZoneInfo("Europe/Prague")
async def get_open_session(
site_id: int, charger_code: str, conn: asyncpg.Connection
) -> asyncpg.Record | None:
return await conn.fetchrow(
"""
select es.id as session_id, es.soc_at_connect_pct, es.target_soc_pct,
es.target_deadline, v.battery_capacity_kwh, v.name as vehicle_name,
v.default_deadline_hour
from ems.ev_session es
join ems.asset_ev_charger c on c.id = es.charger_id
left join ems.asset_vehicle v on v.id = es.vehicle_id
where es.site_id = $1 and c.code = $2 and es.session_end is null
order by es.id desc limit 1
""",
site_id,
charger_code,
)
async def build_ev_plan_summary(
site_id: int, charger_code: str, conn: asyncpg.Connection
) -> str | None:
"""Markdown souhrn: stav baterie auta → cíl, deadline, nabíjecí okna z plánu."""
row = await get_open_session(site_id, charger_code, conn)
if row is None:
return None
ev_col = "ev1_setpoint_w" if charger_code.endswith("1") else "ev2_setpoint_w"
slots = await conn.fetch(
f"""
select pi.interval_start, pi.{ev_col} as w, pi.effective_buy_price
from ems.planning_interval pi
join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = $1 and pr.status = 'active'
and coalesce(pi.{ev_col}, 0) > 0
order by pi.interval_start
""",
site_id,
)
def _fmt(dt) -> str:
return dt.astimezone(_PRAGUE).strftime("%H:%M")
windows: list[str] = []
kwh = 0.0
prices: list[float] = []
if slots:
start = prev = slots[0]["interval_start"]
for r in slots:
ts = r["interval_start"]
if (ts - prev).total_seconds() > 900:
windows.append(f"{_fmt(start)}{_fmt(prev)} (+15m)")
start = ts
prev = ts
kwh += float(r["w"]) * 0.25 / 1000.0
prices.append(float(r["effective_buy_price"] or 0))
windows.append(f"{_fmt(start)}{_fmt(prev)} (+15m)")
soc = row["soc_at_connect_pct"]
tgt = row["target_soc_pct"]
cap = float(row["battery_capacity_kwh"] or 0)
need = max(0.0, (float(tgt or 0) - float(soc or 0)) / 100.0 * cap)
lines = [
f"🔌 **{row['vehicle_name'] or charger_code} připojeno**",
f"Baterie auta: **{soc if soc is not None else '?'} %** → cíl {tgt if tgt is not None else '?'} %"
+ (f" (~{need:.0f} kWh)" if need else ""),
]
dl = row["target_deadline"]
if dl is not None:
lines.append(f"Deadline: {dl.astimezone(_PRAGUE).strftime('%a %d.%m. %H:%M')}")
if windows:
avg_p = sum(prices) / max(1, len(prices))
lines.append(
f"Plán nabíjení: {'; '.join(windows[:4])}{kwh:.1f} kWh, ø {avg_p:.2f} Kč/kWh"
)
else:
lines.append("Plán nabíjení: zatím žádné sloty (čeká na levné okno / PV)")
return "\n".join(lines)
async def send_ev_arrival(site_id: int, charger_code: str, conn: asyncpg.Connection) -> None:
"""Pošle souhrn po příjezdu: přednostně bot s tlačítky, jinak webhook."""
from services.notification_service import send_discord
text = await build_ev_plan_summary(site_id, charger_code, conn)
if text is None:
return
try:
from services.discord_bot import post_ev_arrival
row = await get_open_session(site_id, charger_code, conn)
if row is not None and await post_ev_arrival(
site_id, charger_code, int(row["session_id"]), text
):
return
except Exception:
logger.exception("Discord bot post failed — fallback webhook")
await send_discord(conn, site_id, text, level="info")

View File

@@ -0,0 +1,112 @@
"""Proaktivní notifikace "auto doma + nepíchnuté + levné/přebytek → píchni ho".
Tenký orchestrátor: veškerá doménová logika (kdo je doma, odpojený, výhodná cena,
SoC pod cílem) i dedup jsou v ems.fn_ev_presence_nudge_due(). Python jen zavolá
funkci pro každou aktivní lokalitu a pro každý vrácený (= nově due, ještě
neposlaný) řádek pošle jeden Discord nudge.
Dedup je čistě v DB: funkce zapíše řádek do ems.ev_presence_nudge_sent
(on conflict do nothing) a vrátí jen ty, kterým insert skutečně prošel — tedy
jeden nudge na "epizodu" auta doma+odpojeno. Opakované 2030min ticky proto
nespamují, dokud se auto nepíchne nebo neodjede (čímž se klíč epizody změní).
DEFAULT-OFF: funkce nevrátí nic, dokud není na vozidle
asset_vehicle.presence_nudge_enabled = true. Job tedy běží inertně.
"""
from __future__ import annotations
import logging
from typing import Any
import asyncpg
from app.db_json import fetch_json
from services.notification_service import send_discord
logger = logging.getLogger(__name__)
def _fmt_price(value: Any) -> str:
try:
return f"{float(value):.2f}"
except (TypeError, ValueError):
return "?"
def _build_message(row: asyncpg.Record) -> str:
name = row["vehicle_name"] or "EV"
reason = str(row["trigger_reason"] or "")
sell = row["effective_sell_price_czk_kwh"]
buy = row["effective_buy_price_czk_kwh"]
soc = row["battery_level_pct"]
tgt = row["target_soc_pct"]
if reason == "NEG_OR_ZERO_SELL":
why = f"výkup je teď {_fmt_price(sell)} Kč/kWh (≤ 0) — přebytek se hodí do auta"
else:
why = f"nákup je teď levný: {_fmt_price(buy)} Kč/kWh"
soc_line = ""
if soc is not None:
soc_line = f"\nBaterie auta: **{_fmt_price(soc)} %**" + (
f" (cíl {_fmt_price(tgt)} %)" if tgt is not None else ""
)
return (
f"🚗 **{name} je doma a nepíchnuté** — {why}.{soc_line}\n"
f"Píchni ho a plán se o zbytek postará (přebytky / levné sloty)."
)
async def run_ev_presence_nudge_for_site(
site_id: int, conn: asyncpg.Connection
) -> int:
"""Jedna lokalita: zavolá fn (dedup v DB) a pošle Discord pro každé due vozidlo.
Vrátí počet odeslaných notifikací.
"""
try:
rows = await conn.fetch(
"select * from ems.fn_ev_presence_nudge_due($1::int)",
site_id,
)
except Exception:
logger.exception(
"ev_presence_nudge: fn_ev_presence_nudge_due failed site=%s", site_id
)
return 0
sent = 0
for row in rows:
try:
await send_discord(conn, site_id, _build_message(row), level="info")
sent += 1
logger.info(
"ev_presence_nudge sent site=%s vehicle=%s reason=%s",
site_id,
row["vehicle_id"],
row["trigger_reason"],
)
except Exception:
logger.exception(
"ev_presence_nudge: Discord send failed site=%s vehicle=%s",
site_id,
row["vehicle_id"],
)
return sent
async def run_ev_presence_nudge_for_all_active_sites(pool: asyncpg.Pool) -> None:
"""Scheduler entrypoint: projde aktivní lokality a pošle proaktivní nudge."""
async with pool.acquire() as conn:
raw = await fetch_json(conn, "select ems.fn_vw_site_directory_active()")
sites = raw if isinstance(raw, list) else []
for site in sites:
if not isinstance(site, dict) or site.get("id") is None:
continue
site_id = int(site["id"])
try:
await run_ev_presence_nudge_for_site(site_id, conn)
except Exception:
logger.exception("ev_presence_nudge site=%s failed", site_id)

View File

@@ -25,9 +25,27 @@ logger = logging.getLogger(__name__)
_flock_warned = False
class GatewayLockTimeout(TimeoutError):
"""Brána je držena jiným tahem (telemetrie / druhý proces) déle než timeout."""
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
_DEFAULT_LOCK_DIR = _BACKEND_ROOT / ".ems-modbus-locks"
#: Maximální čekání na exkluzivní zámek brány. Dřív se čekalo blokovaně bez
#: limitu — exporter pak mohl na bráně obsazené pollingem mrtvého unit_id
#: viset donekonečna (journal řádky trvale 'pending'). Po timeoutu se vyhodí
#: GatewayLockTimeout a volající označí příkaz failed ('gateway lock timeout').
_FLOCK_TIMEOUT_DEFAULT_S = 20.0
_FLOCK_POLL_INTERVAL_S = 0.25
def _flock_timeout_s() -> float:
try:
return float(os.getenv("EMS_MODBUS_FLOCK_TIMEOUT_S", _FLOCK_TIMEOUT_DEFAULT_S))
except ValueError:
return _FLOCK_TIMEOUT_DEFAULT_S
def _gateway_lock_path(host: str, port: int) -> Path:
# Výchozí = backend/.ems-modbus-locks (v Dockeru /app → mount ./backend), aby flock sdílel
@@ -65,14 +83,32 @@ async def _gateway_exclusive(host: str, port: int):
path = _gateway_lock_path(host_s, port_i)
path.parent.mkdir(parents=True, exist_ok=True)
f = open(path, "a+b") # noqa: SIM115
locked = False
try:
await asyncio.to_thread(fcntl.flock, f.fileno(), fcntl.LOCK_EX)
# Neblokující pokusy s deadline místo flock(LOCK_EX) bez limitu:
# blokované čekání v to_thread nejde zrušit a při bráně obsazené
# pollingem mrtvého unit_id (32 s z každé minuty) hrozí starvation.
timeout_s = _flock_timeout_s()
deadline = asyncio.get_running_loop().time() + timeout_s
while True:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
locked = True
break
except OSError:
if asyncio.get_running_loop().time() >= deadline:
raise GatewayLockTimeout(
f"gateway lock timeout {host_s}:{port_i} "
f"after {timeout_s:.0f}s"
) from None
await asyncio.sleep(_FLOCK_POLL_INTERVAL_S)
yield
finally:
try:
await asyncio.to_thread(fcntl.flock, f.fileno(), fcntl.LOCK_UN)
except OSError:
pass
if locked:
try:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
except OSError:
pass
f.close()
@@ -260,12 +296,17 @@ class PersistentModbusClient:
return await self._write_registers_locked(address, values, device_id)
async def force_disconnect(self) -> None:
"""Uzavře socket pod lockem (např. před retry po chybě)."""
async with _gateway_exclusive(self.host, self.port):
async with self._lock:
if self._client is not None:
self._client.close()
self._client = None
"""Uzavře socket pod lockem (např. před retry po chybě).
Záměrně BEZ _gateway_exclusive: zavření vlastního TCP socketu není
transakce na RS485 sběrnici a čekání na zámek brány tady umělo
protáhnout / shodit retry cestu exporteru (GatewayLockTimeout
uvnitř except větve execute_modbus_commands).
"""
async with self._lock:
if self._client is not None:
self._client.close()
self._client = None
@asynccontextmanager
async def batch(self, device_id: int = 1) -> AsyncIterator[ModbusBatch]:

View File

@@ -195,24 +195,41 @@ async def send_discord(
"""
kind = "daily" if level == "info" else "error"
webhook_url = await _get_site_webhook_url(conn, site_id, kind)
if not webhook_url:
logger.debug("Discord webhook not configured, skipping notification")
return False
emoji = {"info": "", "warning": "⚠️", "error": "", "critical": "🚨"}.get(level, "")
content = f"{emoji} **EMS** [{level.upper()}]\n{message}"
if webhook_url:
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json={"content": content})
resp.raise_for_status()
return True
except Exception as e:
logger.warning("Discord webhook failed: %s — zkouším bot fallback", e)
# Fallback: bot REST (kanál z DISCORD_EV_CHANNEL_ID) — webhooky per site
# nebyly nikdy nastavené, takže bez fallbacku se notifikace tiše zahazovaly.
return await _send_via_bot(content)
async def _send_via_bot(content: str) -> bool:
s = get_settings()
token = (getattr(s, "discord_bot_token", "") or "").strip()
channel = (getattr(s, "discord_ev_channel_id", "") or "").strip()
if not token or not channel:
logger.debug("Discord: žádný webhook ani bot — notifikace zahozena")
return False
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
webhook_url,
json={
"content": f"{emoji} **EMS Alert** [{level.upper()}]\n{message}",
},
r = await client.post(
f"https://discord.com/api/v10/channels/{channel}/messages",
headers={"Authorization": f"Bot {token}"},
json={"content": content[:1900]},
)
resp.raise_for_status()
r.raise_for_status()
return True
except Exception as e:
logger.warning("Discord notification failed: %s", e)
logger.warning("Discord bot fallback failed: %s", e)
return False

View File

@@ -0,0 +1 @@
"""EMS plánovač moduly (Fáze 1 dekompozice planning_engine.py)."""

View File

@@ -0,0 +1,128 @@
# backend/services/planning/constants.py
#
# EMS plánovač konstanty (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
# POZOR: ekonomické penalty/váhy jsou kandidáti na přesun do DB ve Fázi 2
# (CLAUDE.md pravidlo 16: žádný skrytý faktor v Pythonu).
from zoneinfo import ZoneInfo
# ============================================================
# Konstanty
# ============================================================
# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end).
_DAILY_FALLBACK_HORIZON_HOURS = 1.0
# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh).
INTERVAL_H = 0.25 # 15 minut v hodinách
CURTAILMENT_PENALTY = 0.001 # Kč/Wh malá penalizace za omezení FVE pole A
SOLVER_TIME_LIMIT = 10 # sekund
# MILP: významný export ge (W) ⇒ koncové soc[t] ≥ podlaha; mimo arbitrážní relax je to arb_base_wh
# (rezerva z DB). Při relaxaci spodku před extrémně záporným buy je podlaha soc_panel_min[t]
# (planner floor), jinak by šlo jen do zátěže a nešlo by „vypustit do sítě“ před levným nákupem.
GE_MIN_EXPORT_W = 1.0
# Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh).
ACQUISITION_TWO_PASS_EPS_KWH = 0.05
# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek).
LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05
# Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než
# tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor —
# priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem.
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8
# Měkká kotva: chceme být u planner floor už v posledním slotu před prvním sell < 0.
# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila
# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0.
PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20
PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail).
PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
# Curtailment při sell<0 + allow_charge: nesmí být téměř zdarma oproti nabíjení (BA81).
NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0
# Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif).
NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8
# Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail).
NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35
# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek.
NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0
# Fáze sell<0 (v32): ASAP na prep_soc %, tail rampa na soc_max.
NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.85
NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH = 60.0
# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž).
EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12
NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0
NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0
PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0
PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0
PLANNER_BUILD_TAG = "2026-06-06-home01-strict-late-replan-v5"
SOLVER_RELAX_STEPS: tuple[str, ...] = (
"strict",
"relaxed_expensive_import",
"relaxed_neg_buy_charge",
"relaxed_neg_prep_hold_only",
"relaxed_neg_prep_window",
"neg_sell_phases_fallback",
"relaxed_pos_sell_ge_block",
"relaxed_solver_masks",
)
# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak).
DAWN_LOW_PV_NO_CURTAIL_W = 1500
# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu).
NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0
# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy).
NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0
# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat).
NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0
# Kotva: SoC na konci večera D1 a těsně před 1. sell<0 ráno D ≤ reserve_soc.
NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH = 400.0
NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.0
# Terminal SoC shadow price: effective_factor = base × (1 w_neg); w_neg roste s blízkostí a záporností buy<0.
TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS = int(36 / INTERVAL_H)
TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK = 1.0
TERMINAL_NEG_BUY_MAGNITUDE_FLOOR = 0.25
TERMINAL_NEG_BUY_WEIGHT_CAP = 0.95
# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl.
PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15
PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0
PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 55.0
PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH = 90.0
POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30
PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25
PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0
EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05
# Rolling replan: držet evening_push_ts při malé změně peak sell / SoC.
EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH = 0.5
EVENING_PUSH_HYSTERESIS_SOC_PCT = 5.0
# Noční výprodej baterie: večer (≥17h) + ráno do východu FVE (05h Prague), jedna špička přes půlnoc.
NIGHT_EXPORT_EVENING_START_HOUR = 17
NIGHT_EXPORT_MORNING_END_HOUR = 5
NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W = 500.0
# Převáží terminal SoC shadow price při krátkém večerním horizontu (home-01).
EVENING_PUSH_Z_EXPORT_BONUS_CZK = 2500.0
# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B).
PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0
CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru
CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru
CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru
# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast
CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0
# Dynamická ekonomická podlaha (MILP w_arb): lookahead FVE energie v dalších slotech
ARB_LOOKAHEAD_SLOTS = 32 # 8 h při INTERVAL_H=0.25
ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..1)
_PRAGUE_TZ = ZoneInfo("Europe/Prague")
# --- Konstanty původně roztroušené mezi funkcemi planning_engine.py (Fáze 1) ---
MORNING_PRENEG_START_HOUR = 5
MORNING_PRENEG_END_HOUR = 11
# --- EV anti-fragmentace (Fix B, solver_v2) ---
# IEC 61851 min. nabíjecí proud (A) na fázi. 3f wallbox NEumí jet 1f trickle pod
# 6 A na všech fázích → fyzikální dolní mez dávky je 6 A × phases × napětí.
EV_MIN_CHARGE_CURRENT_A = 6.0
# Síťové napětí fáze (V) pro odhad 3f power floor (3f wallbox: 6 A × 3 × 230 ≈ 4140 W).
EV_PHASE_VOLTAGE_V = 230.0
# Práh, od kolika fází považujeme wallbox za vícefázový (≥ tato hodnota → power floor
# z fází; jinak držíme min_power_w z DB). 3 = jen čistě 3f wallbox dostane 3f floor.
EV_MULTIPHASE_FLOOR_MIN_PHASES = 3

View File

@@ -0,0 +1,472 @@
# backend/services/planning/db_io.py
#
# EMS plánovač DB vrstva: načtení site contextu a slotů, uložení běhu
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
# Jediné SQL: select ems.fn_* (SQL-first pravidlo CLAUDE.md).
import json
import logging
from datetime import datetime
from types import SimpleNamespace
from typing import Any, Optional
from services.planning.constants import (
DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
PLANNER_BUILD_TAG,
)
from services.planning.types import (
PlannerSolverError,
PlanningSlot,
_parse_json_dt,
_slot_float_nullable,
)
logger = logging.getLogger(__name__)
def _ev_session_from_json(obj: object) -> Optional[SimpleNamespace]:
if obj is None or obj == []:
return None
if isinstance(obj, str):
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"))
return SimpleNamespace(
target_deadline=td,
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),
)
async def _load_site_context(site_id: int, db):
"""
Načte baterii, TČ, síť, 2× vozidlo, otevřené EV session, SoC, TUV, režim a TUV statistiky (SQL).
"""
raw = await db.fetchval(
"select ems.fn_planning_site_context($1::int)",
site_id,
)
ctx = raw if isinstance(raw, dict) else json.loads(raw)
if ctx.get("error") == "unknown_site":
raise RuntimeError(f"Site not found: {site_id}")
b = ctx["battery"]
ec_i = int(b["max_charge_power_w"])
ed_i = int(b["max_discharge_power_w"])
planner_soc_max = float(b.get("planner_soc_max_wh", b["soc_max_wh"]))
floor_pct = b.get("planner_discharge_floor_percent")
buy_thr = b.get("planner_extreme_buy_threshold_czk_kwh")
relax_prewin = b.get("planner_discharge_relax_prewindow_slots")
battery = SimpleNamespace(
usable_capacity_wh=float(b["usable_capacity_wh"]),
min_soc_wh=float(b["min_soc_wh"]),
arb_floor_wh=float(b["arb_floor_wh"]),
reserve_soc_wh=float(b["reserve_soc_wh"]),
soc_max_wh=planner_soc_max,
charge_efficiency=float(b["charge_efficiency"]),
discharge_efficiency=float(b["discharge_efficiency"]),
degradation_cost_czk_kwh=float(b["degradation_cost_czk_kwh"]),
max_charge_power_w=ec_i,
max_discharge_power_w=ed_i,
charge_slot_buffer=float(b["charge_slot_buffer"])
if b.get("charge_slot_buffer") is not None
else 0,
discharge_slot_buffer=float(b["discharge_slot_buffer"])
if b.get("discharge_slot_buffer") is not None
else 0,
planner_extreme_buy_threshold_czk_kwh=float(buy_thr) if buy_thr is not None else -5.0,
planner_discharge_floor_percent=float(floor_pct) if floor_pct is not None else None,
planner_discharge_relax_prewindow_slots=int(relax_prewin)
if relax_prewin is not None
else DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]),
planner_daytime_charge_target_enabled=bool(
b.get("planner_daytime_charge_target_enabled", True)
),
planner_night_baseload_buffer_percent=float(
b.get("planner_night_baseload_buffer_percent") or 20.0
),
planner_daytime_charge_price_quantile=float(
b.get("planner_daytime_charge_price_quantile") or 0.70
),
planner_charge_commitment_penalty_czk_kwh=float(
b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20
),
planner_neg_sell_prep_soc_percent=float(
b.get("planner_neg_sell_prep_soc_percent") or 80.0
),
planner_neg_sell_full_soc_tail_slots=int(
b.get("planner_neg_sell_full_soc_tail_slots") or 4
),
planner_safety_soc_risk_factor=float(
b.get("planner_safety_soc_risk_factor") or 0.0
),
planner_pv_risk_frontload_czk_kwh=float(
b.get("planner_pv_risk_frontload_czk_kwh") or 0.0
),
planner_neg_sell_vent_min_sell_czk_kwh=(
float(b["planner_neg_sell_vent_min_sell_czk_kwh"])
if b.get("planner_neg_sell_vent_min_sell_czk_kwh") is not None
else None
),
)
hpj = ctx["heat_pump"]
heat_pump = SimpleNamespace(
rated_heating_power_w=int(hpj["rated_heating_power_w"]),
tuv_min_temp_c=float(hpj["tuv_min_temp_c"]),
tuv_target_temp_c=float(hpj["tuv_target_temp_c"]),
)
g = ctx["grid"]
m = ctx.get("market") or {}
grid = SimpleNamespace(
max_import_power_w=int(g["max_import_power_w"]),
max_export_power_w=int(g["max_export_power_w"]),
block_export_on_negative_sell=bool(g.get("block_export_on_negative_sell") or False),
deye_gen_microinverter_cutoff_enabled=bool(g.get("deye_gen_microinverter_cutoff_enabled") or False),
purchase_pricing_mode=str(m.get("purchase_pricing_mode") or "spot").strip().lower(),
sale_pricing_mode=str(m.get("sale_pricing_mode") or "spot").strip().lower(),
)
vehicles: list[SimpleNamespace] = []
for v in ctx.get("vehicles") or []:
vehicles.append(
SimpleNamespace(
max_charge_power_w=int(v["max_charge_power_w"]),
min_power_w=int(v.get("min_power_w") or 0),
# phases / planner_ev_start_penalty_czk: parametry wallboxu pro
# anti-fragmentaci EV v solver_v2 (Fix B). Default phases=3 (typický
# 3f wallbox), start penalta 0 = no-op (golden-safe).
phases=int(v.get("phases") or 3),
planner_ev_start_penalty_czk=float(
v.get("planner_ev_start_penalty_czk") or 0.0
),
battery_capacity_kwh=float(v["battery_capacity_kwh"]),
default_target_soc_pct=float(v["default_target_soc_pct"]),
)
)
while len(vehicles) < 2:
vehicles.append(
SimpleNamespace(
max_charge_power_w=0,
min_power_w=0,
phases=3,
planner_ev_start_penalty_czk=0.0,
battery_capacity_kwh=1.0,
default_target_soc_pct=80.0,
)
)
ev_raw = ctx.get("ev_sessions") or []
ev_sessions = [
_ev_session_from_json(ev_raw[0]) if len(ev_raw) > 0 else None,
_ev_session_from_json(ev_raw[1]) if len(ev_raw) > 1 else None,
]
soc_wh = float(ctx["soc_wh"])
tuv_temp = float(ctx["tuv_temp"])
operating_mode = ctx.get("operating_mode")
tuv_stats: dict[tuple[int, int], float] = {}
for row in ctx.get("tuv_delta_stats") or []:
tuv_stats[(int(row["dow"]), int(row["hour"]))] = float(row["delta"])
return (
battery,
heat_pump,
grid,
vehicles,
ev_sessions,
soc_wh,
tuv_temp,
operating_mode,
tuv_stats,
)
async def _load_previous_plan_charge_commitment_prev_w(
site_id: int,
slots: list[PlanningSlot],
db,
) -> list[Optional[float]]:
"""
Pro rolling replan: z aktivního plánu načte battery_setpoint_w pro shodné sloty.
Kotva měkkého commitmentu jen když předchozí plán chtěl nabíjet z PV přebytku (viz podmínky).
"""
if not slots:
return []
rows = await db.fetch(
"""
select pi.interval_start,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
coalesce(pi.pv_a_forecast_solver_w, 0) as pva,
coalesce(pi.pv_b_forecast_solver_w, 0) as pvb,
coalesce(pi.load_baseline_w, 0) as lb
from ems.planning_interval pi
inner join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = $1::int
and pr.status = 'active'
""",
site_id,
)
by_start = {r["interval_start"]: r for r in rows}
out: list[Optional[float]] = []
for s in slots:
r = by_start.get(s.interval_start)
if r is None:
out.append(None)
continue
bw = int(r["battery_setpoint_w"] or 0)
gw = int(r["grid_setpoint_w"] or 0)
pva = int(r["pva"] or 0)
pvb = int(r["pvb"] or 0)
lb = int(r["lb"] or 0)
# Commitment má kotvit jen „nabíjení z PV přebytku“, ne situace kdy plán současně
# výrazně exportuje do sítě (typicky charge while exporting). To by stabilizovalo špatný cyklus.
if bw > 500 and (pva + pvb) > lb and gw <= 0 and gw >= -500:
out.append(float(bw))
else:
out.append(None)
return out
async def _load_slots(
site_id: int,
from_dt: datetime,
to_dt: datetime,
db,
*,
soc_wh: float,
) -> list[PlanningSlot]:
"""15min sloty z ems.fn_load_planning_slots_full."""
rows = await db.fetch(
"""
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
ev1_connected, ev2_connected, allow_charge, allow_discharge_export,
night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh,
is_daytime_pv_surplus_slot,
charge_acquisition_buy_czk_kwh, charge_acquisition_cutoff_at,
min_buy_before_cutoff_czk_kwh, pv_charge_wh_ahead, neg_buy_wh_ahead,
grid_charge_suppressed_reason,
charge_target_wh, pre_window_wh, in_window_wh,
charge_slot_wh, charge_cum_wh, charge_layer, charge_slot_reason
from ems.fn_load_planning_slots_full(
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
)
""",
site_id,
from_dt,
to_dt,
soc_wh,
)
out: list[PlanningSlot] = []
for r in rows:
d = dict(r)
out.append(
PlanningSlot(
interval_start=d["interval_start"],
buy_price=float(d["buy_price"]),
sell_price=float(d["sell_price"]),
pv_a_forecast_w=int(d["pv_a_forecast_w"] or 0),
pv_b_forecast_w=int(d["pv_b_forecast_w"] or 0),
load_baseline_w=int(d["load_baseline_w"] or 0),
ev1_connected=bool(d["ev1_connected"]),
ev2_connected=bool(d["ev2_connected"]),
is_predicted_price=bool(d.get("is_predicted_price")),
allow_charge=bool(d.get("allow_charge", True)),
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
night_baseload_target_wh=_slot_float_nullable(d, "night_baseload_target_wh"),
night_baseload_buffer_wh=_slot_float_nullable(d, "night_baseload_buffer_wh"),
safety_soc_target_wh=_slot_float_nullable(d, "safety_soc_target_wh"),
future_avoided_buy_czk_kwh=_slot_float_nullable(d, "future_avoided_buy_czk_kwh"),
future_sell_opportunity_czk_kwh=_slot_float_nullable(
d, "future_sell_opportunity_czk_kwh"
),
is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)),
charge_acquisition_buy_czk_kwh=_slot_float_nullable(
d, "charge_acquisition_buy_czk_kwh"
),
charge_acquisition_cutoff_at=d.get("charge_acquisition_cutoff_at"),
min_buy_before_cutoff_czk_kwh=_slot_float_nullable(
d, "min_buy_before_cutoff_czk_kwh"
),
pv_charge_wh_ahead=_slot_float_nullable(d, "pv_charge_wh_ahead"),
neg_buy_wh_ahead=_slot_float_nullable(d, "neg_buy_wh_ahead"),
grid_charge_suppressed_reason=d.get("grid_charge_suppressed_reason"),
charge_target_wh=_slot_float_nullable(d, "charge_target_wh"),
pre_window_wh=_slot_float_nullable(d, "pre_window_wh"),
in_window_wh=_slot_float_nullable(d, "in_window_wh"),
charge_slot_wh=_slot_float_nullable(d, "charge_slot_wh"),
charge_cum_wh=_slot_float_nullable(d, "charge_cum_wh"),
charge_layer=d.get("charge_layer"),
charge_slot_reason=d.get("charge_slot_reason"),
)
)
if not out:
raise RuntimeError(
"No planning slots available check market prices and horizon settings"
)
if any(s.is_predicted_price for s in out):
logger.warning(
"[site=%s] Unexpected predicted-price slots in planning horizon",
site_id,
)
return out
def _build_slot_inputs(
slots_raw_pv: list[PlanningSlot],
slots_solver: list[PlanningSlot],
) -> list[tuple[int, int, int, int, int]]:
"""(load_baseline_w, pv_a_raw, pv_b_raw, pv_a_solver, pv_b_solver) pro každý slot."""
if len(slots_raw_pv) != len(slots_solver):
raise ValueError("slots_raw_pv and slots_solver length mismatch")
out: list[tuple[int, int, int, int, int]] = []
for raw, sol in zip(slots_raw_pv, slots_solver):
out.append(
(
int(raw.load_baseline_w),
int(raw.pv_a_forecast_w),
int(raw.pv_b_forecast_w),
int(sol.pv_a_forecast_w),
int(sol.pv_b_forecast_w),
)
)
return out
async def _save_planning_run(
site_id, results, horizon_from, horizon_to,
run_type, triggered_by, replan_from,
soc_wh, duration_ms, correction, db,
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
*,
activate_run: bool = True,
solver_snapshot: Optional[dict[str, Any]] = None,
) -> int:
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
if slot_inputs is not None and len(slot_inputs) != len(results):
raise ValueError("slot_inputs and results length mismatch")
run_meta: dict[str, Any] = {
"run_type": run_type,
"triggered_by": triggered_by,
"replan_from": replan_from.isoformat() if replan_from else None,
"soc_at_replan_wh": soc_wh,
"solver_duration_ms": duration_ms,
"forecast_correction_factor": correction,
}
if solver_snapshot is not None:
run_meta["solver_params"] = solver_snapshot
intervals: list[dict] = []
for i, r in enumerate(results):
row: dict = {
"interval_start": r.interval_start.isoformat()
if hasattr(r.interval_start, "isoformat")
else r.interval_start,
"battery_setpoint_w": r.battery_setpoint_w,
"battery_soc_target_pct": r.battery_soc_target,
"grid_setpoint_w": r.grid_setpoint_w,
"export_limit_w": r.export_limit_w,
"export_mode": r.export_mode,
"deye_physical_mode": r.deye_physical_mode,
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
"ev1_setpoint_w": r.ev1_setpoint_w,
"ev2_setpoint_w": r.ev2_setpoint_w,
"ev1_via_bat_w": r.ev1_via_bat_w,
"ev2_via_bat_w": r.ev2_via_bat_w,
"heat_pump_enabled": r.heat_pump_enabled,
"heat_pump_setpoint_w": r.heat_pump_setpoint_w,
"pv_a_curtailed_w": r.pv_a_curtailed_w,
"expected_cost_czk": float(r.expected_cost_czk),
"cashflow_czk": float(r.cashflow_czk),
"battery_arbitrage_czk": float(r.battery_arbitrage_czk),
"penalty_czk": float(r.penalty_czk),
"green_bonus_czk": float(r.green_bonus_czk),
"effective_buy_price": float(r.effective_buy_price),
"effective_sell_price": float(r.effective_sell_price),
"is_predicted_price": r.is_predicted_price,
}
if slot_inputs is not None:
si = slot_inputs[i]
row["load_baseline_w"] = si[0]
row["pv_a_forecast_raw_w"] = si[1]
row["pv_b_forecast_raw_w"] = si[2]
row["pv_a_forecast_solver_w"] = si[3]
row["pv_b_forecast_solver_w"] = si[4]
intervals.append(row)
return int(
await db.fetchval(
"""
select ems.fn_planning_run_commit(
$1::int, $2::timestamptz, $3::timestamptz,
$4::jsonb, $5::jsonb, $6::boolean
)
""",
site_id,
horizon_from,
horizon_to,
json.dumps(run_meta, default=str),
json.dumps(intervals, default=str),
activate_run,
)
)
async def _save_failed_planning_run(
site_id: int,
horizon_from: datetime,
horizon_to: datetime,
*,
run_type: str,
triggered_by: str,
replan_from: datetime | None,
soc_wh: float,
correction: float,
db,
error: PlannerSolverError,
slot_count: int | None = None,
) -> int:
"""Uloží neúspěšný běh plánovače (status=failed); aktivní plán nemění."""
run_meta: dict[str, Any] = {
"run_type": run_type,
"triggered_by": triggered_by,
"replan_from": replan_from.isoformat() if replan_from else None,
"soc_at_replan_wh": soc_wh,
"solver_duration_ms": 0,
"forecast_correction_factor": correction,
"error_text": str(error),
"solver_params": {
"status": "failed",
"planner_build_tag": PLANNER_BUILD_TAG,
"solver_status": error.solver_status,
"relax_chain": error.relax_chain,
"slot_count": slot_count,
},
}
run_id = int(
await db.fetchval(
"""
select ems.fn_planning_run_fail(
$1::int, $2::timestamptz, $3::timestamptz, $4::jsonb
)
""",
site_id,
horizon_from,
horizon_to,
json.dumps(run_meta, default=str),
)
)
logger.error(
"[site=%s] Planning solver failed run_id=%s: %s relax_chain=%s",
site_id,
run_id,
error,
error.relax_chain,
)
return run_id

View File

@@ -0,0 +1,97 @@
# backend/services/planning/forecast.py
#
# EMS plánovač korekce FVE forecastu podle skutečné výroby
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
import json
import logging
from dataclasses import replace
from datetime import datetime, timedelta
from typing import Optional
from services.planning.constants import (
CORRECTION_DECAY_SLOTS,
CORRECTION_MAX_CLAMP,
CORRECTION_MIN_CLAMP,
CORRECTION_WINDOW_H,
)
from services.planning.types import PlanningSlot, _parse_json_dt
logger = logging.getLogger(__name__)
async def compute_correction_factor(
site_id: int,
now: datetime,
db,
window_h: float = CORRECTION_WINDOW_H,
) -> tuple[float, dict]:
"""
Spočítá korekční faktor FVE forecastu z posledních window_h hodin.
Vrátí (factor, log_data) kde factor je v rozsahu [CORRECTION_MIN_CLAMP, CORRECTION_MAX_CLAMP].
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
"""
window_start = now - timedelta(hours=window_h)
raw = await db.fetchval(
"""
select ems.fn_pv_forecast_correction_factor(
$1::int, $2::timestamptz, $3::timestamptz,
$4::numeric, $5::numeric
)
""",
site_id,
window_start,
now,
CORRECTION_MIN_CLAMP,
CORRECTION_MAX_CLAMP,
)
j = raw if isinstance(raw, dict) else json.loads(raw)
factor = float(j.get("correction_factor", 1.0))
# JSON z DB má často ISO řetězce; asyncpg u $2/$3 vyžaduje datetime
ws = _parse_json_dt(j.get("window_start")) or window_start
we = _parse_json_dt(j.get("window_end")) or now
log_data = {
"window_start": ws,
"window_end": we,
"actual_pv_wh": j.get("actual_pv_wh"),
"forecast_pv_wh": j.get("forecast_pv_wh"),
"correction_factor": factor,
"reason": j.get("reason", "ok"),
}
if j.get("raw_factor") is not None:
log_data["raw_factor"] = j["raw_factor"]
return factor, log_data
def apply_forecast_correction(
slots: list[PlanningSlot],
now: datetime,
factor: float,
decay_slots: int = CORRECTION_DECAY_SLOTS,
) -> list[PlanningSlot]:
"""
Aplikuje korekční faktor na FVE forecast zbývajících slotů.
Korekce se lineárně utlumuje: na 1. slotu plná korekce,
na decay_slots-tém slotu žádná korekce.
Příklad: factor=0.85, slot 0 → pv_a *= 0.85, slot 8 → pv_a *= 0.925, slot 16+ → žádná korekce
"""
corrected = []
for i, slot in enumerate(slots):
if factor == 1.0 or i >= decay_slots:
corrected.append(slot)
continue
# Lineární útlum: weight klesá od 1.0 (slot 0) do 0.0 (slot decay_slots)
weight = 1.0 - (i / decay_slots)
effective_factor = 1.0 + (factor - 1.0) * weight
corrected.append(
replace(
slot,
pv_a_forecast_w=max(0, int(slot.pv_a_forecast_w * effective_factor)),
pv_b_forecast_w=max(0, int(slot.pv_b_forecast_w * effective_factor)),
)
)
return corrected

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,623 @@
# backend/services/planning/solver_v2.py
#
# EMS plánovač v2 — ČISTÉ ekonomické jádro (Fáze 3).
#
# Filozofie: objective = reálné peníze (nákup prodej + degradace terminal
# hodnota energie). Žádné heuristické penalty z constants.py, žádné pre-solver
# fáze/okna/kotvy. Chování (neg-sell příprava, evening export, arbitráž) má
# VYPLYNOUT z cen a fyziky, ne z ručně laděných vah.
#
# Co zůstává (tvrdá pravidla — fyzika, HW, CLAUDE.md):
# - bilance sběrnice, SoC dynamika s účinnostmi, výkonové stropy
# - curtailment jen pole A (pravidlo 5); GEN cutoff binárka pole B (pravidlo 6)
# - block_export_on_negative_sell → ge == 0 při sell < 0 (pravidlo 6, KV1)
# - buy < 0 → ge == 0 (žádná pumpa importexport přes jeden elektroměr; import
# je omezen breakerem — pravidlo 7)
# - export z BATERIE ⇒ koncové SoC ≥ arb floor (pravidlo 19; PV export floor nevynucuje)
# - zákaz současného importu a exportu (binárka)
# - load-first Deye: bc_pv + ge_pv jen z PV přebytku nad zátěží
# - EV deadline, TUV look-ahead, provozní režimy (legitimní constraints)
# - noční SoC polštář: plán nesmí kalkulovat s vybitím až na min_soc — chyba
# predikce noční spotřeby by znamenala neplánovaný noční nákup. Velikost
# z DB (planner_night_baseload_buffer_percent → slot.night_baseload_buffer_wh,
# klesá k 0 do rána); porušení je PLACENÉ cenou buy daného slotu (riziko
# zpětného nákupu), takže extrémní sell špička ho smí racionálně prodat.
# - PV-risk front-load: v okně sell<0 je nabíjení z PV zdarma kdykoliv →
# indiference v čase; odložení ale spoléhá na predikci (večerní mrak).
# Malá prémie za držení energie dřív (DB planner_pv_risk_frontload_czk_kwh)
# vede k "nabít plným výkonem hned, pak řezat A" — emergentně, bez rampy.
# - oportunistické EV („měkký cíl"): nad tvrdý target smí auto vzít až
# headroom_wh (do 100 %), oceněno opportunistic_value_czk_kwh (= budoucí
# ušetřené nabíjení, session override → vozidlo, DB) — kupuje jen velmi
# levnou/zápornou energii. Dekompozice Σ(EV energie) == needed unmet + opp
# zároveň stropuje celkovou energii do auta (dřív při buy<0 bez stropu);
# opp vrstva NENÍ vázaná deadline (auto bývá doma dál, odjezd řeší rolling
# replan); bez session je EV == 0 (stop-session). Deadline suma jde po
# slot PŘED deadline (slot začínající v deadline už nepatří „do deadline").
# - min. výkon wallboxu (asset_ev_charger.min_power_w, 6 A ≈ 1380 W):
# binárka ev_on → setpoint ∈ {0} [min_power_w, max]; ev_direct ≤ gi + PV
# (fyzikální split direct/via_bat). Reporting: kWh přes ev_via_bat plní
# battery_arbitrage_czk oportunitní cenou (min sell exportního slotu dne,
# jinak terminal value) — slotový buy pro ně neplatí. U TŘÍFÁZOVÉHO wallboxu
# (asset_ev_charger.phases ≥ 3) je floor zvednut na 6 A × fáze × 230 V (≈ 4140
# W pro 3f) místo 1f ~1380 W → ruší sub-6A 1f trickle drobky (cap = max výkon
# vozidla). Fáze/min jdou z DB přes vehicle kontext (R__039).
# - anti-fragmentace EV (Fix B): per-slot binárka ev_on (vždy při floor NEBO
# start penaltě) + hrana ev_start[t] ≥ ev_on[t] ev_on[t1]; objektiv +=
# Σ ev_start × asset_ev_charger.planner_ev_start_penalty_czk (Kč). Drobná
# penalta (filozofie v2: nejistota/opotřebení = cena, ne tvrdá priorita) →
# souvislá dávka místo rozsekání. Default 0 = no-op (golden-safe).
# - denní SoC rampa: deficit pod slot.safety_soc_target_wh (R__063: reserve →
# reserve+noc, 619 h) platí za slot nájem buy×faktor (DB
# planner_safety_soc_risk_factor) — ráno se nejdřív dotáhne rezerva
# (nenadálý odběr by se kupoval draho), pak se prodává.
#
# Vědomé odchylky od v1 (změří harness):
# - SQL masky allow_charge / allow_discharge_export se IGNORUJÍ (jsou to
# výstupy charge-slot-budget heuristik, ne fyzika)
# - EV náklady jen přes bilanci (v1 je účtuje navíc v objective — dvojí započtení)
# - import breaker je tvrdý strop (v1 měkký s 10 Kč/kWh)
# - nedodaná EV energie má explicitní cenu místo infeasibility
from __future__ import annotations
import logging
import time
from typing import Any, Optional
import pulp
from services.planning.constants import (
EV_MIN_CHARGE_CURRENT_A,
EV_MULTIPHASE_FLOOR_MIN_PHASES,
EV_PHASE_VOLTAGE_V,
INTERVAL_H,
SOLVER_TIME_LIMIT,
)
from services.planning.types import (
DispatchResult,
PlanningSlot,
_prague_calendar_date,
_prague_dow_hour,
)
from services.planning.heuristics import _dispatch_grid_setpoint_w
logger = logging.getLogger(__name__)
V2_BUILD_TAG = "v2-ev-accounting-2026-06-12"
# Cena za vypnutí GEN portu (mikroinvertory pole B): reálné riziko/opotřebení
# cyklování stykače — drobná, ale nenulová, aby cutoff platil jen při sell < 0.
V2_GEN_CUTOFF_CZK_KWH = 2.0
# SELF_SUSTAIN: export je nežádoucí, ale tvrdé ge=0 by s neřiditelným polem B
# a plnou baterií bylo infeasible — vysoká cena funguje jako ventil.
V2_SELF_SUSTAIN_EXPORT_CZK_KWH = 100.0
# Cena nedodané EV energie do deadline (Kč/kWh) — místo tvrdé infeasibility.
V2_EV_UNMET_CZK_KWH = 50.0
# Nepatrný tie-break proti zbytečnému curtailu při cenové indiferenci (Kč/kWh).
V2_CURTAIL_TIEBREAK_CZK_KWH = 0.001
def _terminal_value_czk_per_wh(slots: list[PlanningSlot], battery: Any) -> float:
"""Shadow cena zbytkové energie: průměrný buy prvních 24 h × DB faktor (pravidlo 16)."""
n24 = min(len(slots), int(24 / INTERVAL_H))
avg_buy = sum(float(s.buy_price) for s in slots[:n24]) / max(1, n24)
factor = float(getattr(battery, "planner_terminal_soc_value_factor", 1.0) or 1.0)
return max(0.0, avg_buy) * factor / 1000.0
def _arb_floor_wh(battery: Any) -> float:
"""Podlaha SoC pro export z baterie (pravidlo 19): ekonomická rezerva z DB."""
floor = getattr(battery, "arb_floor_wh", None)
if floor is None:
floor = getattr(battery, "reserve_soc_wh", None)
return max(float(floor or 0.0), float(battery.min_soc_wh))
def solve_dispatch_v2(
slots: list[PlanningSlot],
battery: Any,
heat_pump: Any,
grid: Any,
ev_sessions: list,
vehicles: list,
current_soc_wh: float,
current_tuv_temp_c: float,
*,
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
operating_mode: str = "AUTO",
planner_version: str | None = None,
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
"""Čistý ekonomický MILP; rozhraní kompatibilní se solve_dispatch (v1)."""
if not slots:
raise RuntimeError("solve_dispatch_v2 requires at least one slot")
t0 = time.monotonic()
T = len(slots)
om = (operating_mode or "AUTO").upper()
EV = min(len(vehicles), 2)
max_imp = float(grid.max_import_power_w)
max_exp = float(grid.max_export_power_w)
max_chg = float(battery.max_charge_power_w)
max_dis = float(battery.max_discharge_power_w)
eff_c = float(battery.charge_efficiency)
eff_d = float(battery.discharge_efficiency)
deg = float(battery.degradation_cost_czk_kwh)
soc_min = float(battery.min_soc_wh)
soc_max = float(battery.soc_max_wh)
usable = float(battery.usable_capacity_wh)
arb_floor = _arb_floor_wh(battery)
terminal = _terminal_value_czk_per_wh(slots, battery)
block_neg_sell = bool(getattr(grid, "block_export_on_negative_sell", False))
gen_cutoff_avail = bool(getattr(grid, "deye_gen_microinverter_cutoff_enabled", False))
soc0 = min(max(float(current_soc_wh), soc_min), soc_max)
prob = pulp.LpProblem("dispatch_v2", pulp.LpMinimize)
gi = [pulp.LpVariable(f"gi_{t}", 0, max_imp) for t in range(T)]
ge_pv = [pulp.LpVariable(f"gepv_{t}", 0, max_exp) for t in range(T)]
ge_bat = [pulp.LpVariable(f"gebat_{t}", 0, max_exp) for t in range(T)]
bc_pv = [pulp.LpVariable(f"bcpv_{t}", 0, max_chg) for t in range(T)]
bc_gi = [pulp.LpVariable(f"bcgi_{t}", 0, max_chg) for t in range(T)]
bd = [pulp.LpVariable(f"bd_{t}", 0, max_dis) for t in range(T)]
ca = [pulp.LpVariable(f"ca_{t}", 0, max(0, int(slots[t].pv_a_forecast_w))) for t in range(T)]
soc = [pulp.LpVariable(f"soc_{t}", soc_min, soc_max) for t in range(T)]
hp = [pulp.LpVariable(f"hp_{t}", 0, float(heat_pump.rated_heating_power_w)) for t in range(T)]
y_imp = [pulp.LpVariable(f"yimp_{t}", cat=pulp.LpBinary) for t in range(T)]
z_exp = [pulp.LpVariable(f"zexp_{t}", cat=pulp.LpBinary) for t in range(T)]
z_gen = (
[pulp.LpVariable(f"zgen_{t}", cat=pulp.LpBinary) for t in range(T)]
if gen_cutoff_avail
else None
)
ev_direct = [
[
pulp.LpVariable(f"evd_{e}_{t}", 0, min(float(vehicles[e].max_charge_power_w), max_imp))
for t in range(T)
]
for e in range(EV)
]
ev_via_bat = [
[
pulp.LpVariable(f"evb_{e}_{t}", 0, float(vehicles[e].max_charge_power_w))
for t in range(T)
]
for e in range(EV)
]
ev_unmet: list = [] # slack Wh per session (cena V2_EV_UNMET_CZK_KWH)
ev_opp: list = [] # (var, value_czk_kwh) — energie nad target (měkký cíl)
ev_start_terms: list = [] # (ev_start var, penalta Kč) — anti-fragmentace (Fix B)
def _ev_min_power_w(e: int) -> float:
"""Dolní mez nabíjecí dávky (W): u 3f wallboxu fyzikální 6 A × fáze × napětí
(≈ 4140 W) místo 1f ~1380 W → zruší sub-6A 1f trickle. Stropuje se max
výkonem vozidla (jinak by připojený slot byl infeasible). Bez spolehlivého
počtu fází padá zpět na min_power_w z DB."""
veh = vehicles[e]
base_min = max(0.0, float(getattr(veh, "min_power_w", 0) or 0))
phases = int(getattr(veh, "phases", 0) or 0)
ev_max = float(veh.max_charge_power_w)
if phases >= EV_MULTIPHASE_FLOOR_MIN_PHASES:
floor = EV_MIN_CHARGE_CURRENT_A * phases * EV_PHASE_VOLTAGE_V
base_min = max(base_min, floor)
# strop max výkonem vozidla — floor nesmí překročit, co auto/wallbox umí
if ev_max > 0:
base_min = min(base_min, ev_max)
return base_min
def _ev_start_penalty_czk(e: int) -> float:
return max(0.0, float(getattr(vehicles[e], "planner_ev_start_penalty_czk", 0.0) or 0.0))
ev_min_w = [_ev_min_power_w(e) for e in range(EV)]
ev_start_pen = [_ev_start_penalty_czk(e) for e in range(EV)]
# ev_on[e][t]: zapnutost wallboxu v slotu. Vždy potřeba, pokud platí min-power
# floor (gate) NEBO start penalta (anti-fragmentace). ev_start[e][t]: náběžná
# hrana ev_on (start nové dávky) — jen když je start penalta > 0 (jinak žádný
# extra MILP balast a default 0 = no-op, golden-safe).
ev_needs_on = [(ev_min_w[e] > 0.0) or (ev_start_pen[e] > 0.0) for e in range(EV)]
ev_on = [
[
pulp.LpVariable(f"evon_{e}_{t}", cat=pulp.LpBinary)
for t in range(T)
]
if ev_needs_on[e]
else None
for e in range(EV)
]
ev_start = [
[
pulp.LpVariable(f"evstart_{e}_{t}", 0, 1)
for t in range(T)
]
if ev_start_pen[e] > 0.0
else None
for e in range(EV)
]
nb_buffer_wh = [max(0.0, float(s.night_baseload_buffer_wh or 0.0)) for s in slots]
safety_risk = float(getattr(battery, "planner_safety_soc_risk_factor", 0.0) or 0.0)
safety_tgt_wh = [
min(soc_max, max(0.0, float(s.safety_soc_target_wh or 0.0)))
if safety_risk > 0 else 0.0
for s in slots
]
ds_slack = [
pulp.LpVariable(f"dss_{t}", 0, soc_max) if safety_tgt_wh[t] > 0 else None
for t in range(T)
]
nb_slack = [
pulp.LpVariable(f"nbs_{t}", 0, nb_buffer_wh[t]) if nb_buffer_wh[t] > 0 else None
for t in range(T)
]
def _connected(e: int, t: int) -> bool:
return bool(slots[t].ev1_connected if e == 0 else slots[t].ev2_connected)
for t in range(T):
s = slots[t]
pv_a = max(0.0, float(s.pv_a_forecast_w))
pv_b = max(0.0, float(s.pv_b_forecast_w))
pv_a_net = pv_a - ca[t]
pv_b_eff = pv_b - (pv_b * z_gen[t] if z_gen is not None else 0.0)
ev_total_t = pulp.lpSum(
ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV)
)
load_site = float(s.load_baseline_w) + ev_total_t + hp[t]
# bilance sběrnice (W)
prob += (
pv_a_net + pv_b_eff + gi[t] + bd[t]
== load_site + bc_pv[t] + bc_gi[t] + ge_pv[t] + ge_bat[t]
), f"balance_{t}"
# SoC dynamika (Wh)
prev = soc0 if t == 0 else soc[t - 1]
prob += (
soc[t]
== prev
+ (bc_pv[t] + bc_gi[t]) * eff_c * INTERVAL_H
- bd[t] / eff_d * INTERVAL_H
), f"soc_{t}"
# výkonové stropy
prob += bc_pv[t] + bc_gi[t] <= max_chg, f"chg_cap_{t}"
prob += ge_pv[t] + ge_bat[t] <= max_exp, f"exp_cap_{t}"
# PV cesty omezené dostupnou výrobou (load-first vynucuje HW; bilance účtuje energii)
prob += bc_pv[t] + ge_pv[t] <= pv_a_net + pv_b_eff, f"pv_src_{t}"
# bc_gi jen ze sítě:
prob += bc_gi[t] <= gi[t], f"bcgi_src_{t}"
# vybíjení kryje dům + EV-via-bat + export z baterie
prob += ge_bat[t] + pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t], f"bd_split_{t}"
# ev_direct fyzicky jen ze sítě + PV (ne z baterie) — split direct/via_bat
# není arbitrární, ekonomiku nemění (bilance platí stejně)
prob += (
pulp.lpSum(ev_direct[e][t] for e in range(EV)) <= gi[t] + pv_a_net + pv_b_eff
), f"evd_src_{t}"
# zákaz současného importu a exportu
prob += gi[t] <= max_imp * y_imp[t], f"imp_excl_{t}"
prob += ge_pv[t] + ge_bat[t] <= max_exp * (1 - y_imp[t]), f"exp_excl_{t}"
# pravidlo 19: export z baterie ⇒ SoC ≥ arb floor
prob += ge_bat[t] <= max_exp * z_exp[t], f"zexp_link_{t}"
prob += soc[t] >= arb_floor - (soc_max - soc_min) * (1 - z_exp[t]), f"zexp_floor_{t}"
# noční SoC polštář (viz hlavička): soft floor nad min_soc
if nb_slack[t] is not None:
prob += soc[t] >= soc_min + nb_buffer_wh[t] - nb_slack[t], f"night_buf_{t}"
# denní SoC rampa (viz hlavička): soft floor k safety targetu
if ds_slack[t] is not None:
prob += soc[t] >= safety_tgt_wh[t] - ds_slack[t], f"day_safety_{t}"
# tvrdá cenová pravidla
if float(s.buy_price) < 0.0:
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_buy_noexp_{t}"
if float(s.sell_price) < 0.0 and block_neg_sell:
prob += ge_pv[t] + ge_bat[t] == 0, f"neg_sell_block_{t}"
# EV dostupnost + min. výkon wallboxu (binárka ev_on) + start hrana.
# ev_on existuje, když platí min-power floor NEBO start penalta.
for e in range(EV):
on_t = ev_on[e][t] if ev_on[e] is not None else None
if not _connected(e, t):
prob += ev_direct[e][t] == 0
prob += ev_via_bat[e][t] == 0
if on_t is not None:
prob += on_t == 0, f"ev_off_{e}_{t}"
else:
ev_max_w = float(vehicles[e].max_charge_power_w)
ev_total = ev_direct[e][t] + ev_via_bat[e][t]
if on_t is not None and ev_max_w > 0:
# on=1 nutné kdykoli ev_total > 0 (start penalta i floor to potřebují)
prob += ev_total <= ev_max_w * on_t, f"ev_max_{e}_{t}"
if 0 < ev_min_w[e] <= ev_max_w:
prob += ev_total >= ev_min_w[e] * on_t, f"ev_min_{e}_{t}"
else:
prob += ev_total <= ev_max_w
# start = náběžná hrana ev_on (≥ on[t] on[t1]); slot 0 startuje vždy,
# když je on (žádný předchozí stav v horizontu).
if ev_start[e] is not None and on_t is not None:
prev_on = ev_on[e][t - 1] if t > 0 else 0
prob += ev_start[e][t] >= on_t - prev_on, f"ev_start_{e}_{t}"
# provozní režimy (tvrdé constraints dle operating-modes.md)
if om == "SELF_SUSTAIN":
prob += gi[t] <= float(s.load_baseline_w), f"ss_gi_{t}"
elif om == "PRESERVE":
prob += bc_pv[t] == 0
prob += bc_gi[t] == 0
prob += bd[t] == 0
elif om == "CHARGE_CHEAP":
prob += ge_pv[t] + ge_bat[t] == 0
prob += bd[t] == 0
# EV deadline (s placeným slackem místo infeasibility) + měkký cíl.
# Bez session není mandát nabíjet: připojené auto bez session (stop-session,
# golden fixtures s vynulovanými sessions) nesmí při buy<0 „pumpovat" energii.
for e in range(EV):
sess = ev_sessions[e] if e < len(ev_sessions) else None
if sess is None:
for t in range(T):
if _connected(e, t):
prob += ev_direct[e][t] == 0, f"ev_nosess_d_{e}_{t}"
prob += ev_via_bat[e][t] == 0, f"ev_nosess_b_{e}_{t}"
continue
needed = max(0.0, float(getattr(sess, "energy_needed_wh", 0.0) or 0.0))
unmet = pulp.LpVariable(f"ev_unmet_{e}", 0, needed)
ev_unmet.append(unmet)
if needed > 0:
# první slot s interval_start >= deadline už do deadline NEPATŘÍ
# (slot [deadline, deadline+15min) dodává energii až po odjezdu)
t_dl = next(
(t for t in range(T) if slots[t].interval_start >= sess.target_deadline),
T,
)
prob += (
pulp.lpSum(
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
for t in range(t_dl)
if _connected(e, t)
)
+ unmet
>= needed
), f"ev_deadline_{e}"
# měkký cíl: dekompozice celkové energie == needed unmet + opp.
# Oportunistická vrstva NENÍ omezená deadline — auto bývá doma dál,
# odjezd řeší rolling replan (rozhodnutí 2026-06-12).
headroom = max(0.0, float(getattr(sess, "headroom_wh", 0.0) or 0.0))
opp_val = float(getattr(sess, "opportunistic_value_czk_kwh", 0.0) or 0.0)
opp = pulp.LpVariable(f"ev_opp_{e}", 0, headroom if opp_val > 0 else 0.0)
ev_opp.append((opp, opp_val))
prob += (
pulp.lpSum(
(ev_direct[e][t] + ev_via_bat[e][t]) * INTERVAL_H
for t in range(T)
if _connected(e, t)
)
== needed - unmet + opp
), f"ev_total_{e}"
# TUV look-ahead (převzato z v1 — komfortní constraint, ne heuristika)
rated_hp = float(heat_pump.rated_heating_power_w)
if tuv_delta_stats and rated_hp > 0 and getattr(heat_pump, "tuv_min_temp_c", None):
tuv_pred = float(current_tuv_temp_c)
tgt = float(getattr(heat_pump, "tuv_target_temp_c", 55.0) or 55.0)
thr = float(heat_pump.tuv_min_temp_c) + 5.0
for t in range(T):
dow, hour = _prague_dow_hour(slots[t].interval_start)
delta = tuv_delta_stats.get((dow, hour), -0.1)
tuv_pred += float(delta) * INTERVAL_H
if tuv_pred < thr:
prob += (
pulp.lpSum(hp[s_] for s_ in range(max(0, t - 8), t + 1))
>= rated_hp * 0.5
), f"tuv_heat_{t}"
tuv_pred = tgt
if float(current_tuv_temp_c) < float(heat_pump.tuv_min_temp_c):
prob += hp[0] >= rated_hp * 0.8, "tuv_emergency"
# ---------------- objective: jen reálné peníze ----------------
wh = INTERVAL_H / 1000.0 # W → kWh za slot
cash = pulp.lpSum(
gi[t] * float(slots[t].buy_price) * wh
- (ge_pv[t] + ge_bat[t]) * float(slots[t].sell_price) * wh
for t in range(T)
)
degradation = pulp.lpSum(
0.5 * (bc_pv[t] + bc_gi[t] + bd[t]) * deg * wh for t in range(T)
)
extras = pulp.lpSum(ca[t] * V2_CURTAIL_TIEBREAK_CZK_KWH * wh for t in range(T))
if z_gen is not None:
extras += pulp.lpSum(
max(0.0, float(slots[t].pv_b_forecast_w)) * z_gen[t] * V2_GEN_CUTOFF_CZK_KWH * wh
for t in range(T)
)
if om == "SELF_SUSTAIN":
extras += pulp.lpSum(
(ge_pv[t] + ge_bat[t]) * V2_SELF_SUSTAIN_EXPORT_CZK_KWH * wh for t in range(T)
)
if ev_unmet:
extras += pulp.lpSum(u * V2_EV_UNMET_CZK_KWH / 1000.0 for u in ev_unmet)
if ev_opp:
extras -= pulp.lpSum(o / 1000.0 * val for o, val in ev_opp if val > 0)
# anti-fragmentace EV (Fix B): Σ ev_start × start_penalta (Kč). Default 0 → no-op.
ev_start_terms = [
ev_start[e][t] * ev_start_pen[e]
for e in range(EV)
if ev_start[e] is not None and ev_start_pen[e] > 0.0
for t in range(T)
]
if ev_start_terms:
extras += pulp.lpSum(ev_start_terms)
nb_terms = [
nb_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price))
for t in range(T)
if nb_slack[t] is not None
]
if nb_terms:
extras += pulp.lpSum(nb_terms)
ds_terms = [
ds_slack[t] / 1000.0 * max(0.0, float(slots[t].buy_price)) * safety_risk
for t in range(T)
if ds_slack[t] is not None
]
if ds_terms:
extras += pulp.lpSum(ds_terms)
frontload = float(getattr(battery, "planner_pv_risk_frontload_czk_kwh", 0.0) or 0.0)
neg_idx = [t for t in range(T) if float(slots[t].sell_price) < 0.0]
if frontload > 0 and neg_idx:
# odměna za soc[t] v neg slotech = dřívější nabití vyhrává při indiferenci
extras -= pulp.lpSum(soc[t] / 1000.0 * frontload for t in neg_idx)
prob += cash + degradation + extras - terminal * soc[T - 1]
solver = (
pulp.HiGHS_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
if pulp.HiGHS_CMD().available()
else pulp.PULP_CBC_CMD(msg=False, timeLimit=SOLVER_TIME_LIMIT)
)
status = prob.solve(solver)
duration_ms = int((time.monotonic() - t0) * 1000)
status_str = pulp.LpStatus[status]
if status_str != "Optimal":
# v2 nemá relax řetězec — model je navržen tak, aby byl feasible
# (placené slacky místo tvrdých kotev). Ne-Optimal je skutečná chyba.
raise RuntimeError(f"solver_v2: {status_str}")
# ---------------- DispatchResult assembly (parita s v1) ----------------
def _val(var) -> float:
v = pulp.value(var)
return float(v) if v is not None else 0.0
# Reporting EV-via-bat: kWh do auta z baterie neplatí slotový buy (jdou
# z baterie), ale ušlou příležitost. Aproximace oportunitní ceny: nejnižší
# sell slotu, kde plán exportuje, v témže pražském dni; bez exportu ten den
# terminal value (Kč/kWh). Plní battery_arbitrage_czk (dřív konstantní 0).
day_min_export_sell: dict[Any, float] = {}
for t in range(T):
if _val(ge_pv[t]) + _val(ge_bat[t]) >= 1.0:
d_key = _prague_calendar_date(slots[t])
sp = float(slots[t].sell_price)
if d_key not in day_min_export_sell or sp < day_min_export_sell[d_key]:
day_min_export_sell[d_key] = sp
results: list[DispatchResult] = []
for t in range(T):
s = slots[t]
via1_w = _val(ev_via_bat[0][t]) if EV > 0 else 0.0
via2_w = _val(ev_via_bat[1][t]) if EV > 1 else 0.0
via_kwh = (via1_w + via2_w) * wh
if via_kwh > 1e-9:
opp_price = max(
0.0,
day_min_export_sell.get(_prague_calendar_date(s), terminal * 1000.0),
)
arb_czk = via_kwh * opp_price
else:
arb_czk = 0.0
bc_tot = _val(bc_pv[t]) + _val(bc_gi[t])
bd_v = _val(bd[t])
batt_w = round(bc_tot - bd_v)
ge_pv_w = round(_val(ge_pv[t]))
ge_bat_w = round(_val(ge_bat[t]))
gi_w = _val(gi[t])
ge_w = float(ge_pv_w + ge_bat_w)
grid_w, export_mode = _dispatch_grid_setpoint_w(
gi_w=gi_w,
ge_w=ge_w,
ge_bat_w=float(ge_bat_w),
ge_pv_w=float(ge_pv_w),
max_export_power_w=int(max_exp),
)
if batt_w < 0 and grid_w < 0:
deye_mode = "SELL"
elif batt_w > 0 and grid_w > 0:
deye_mode = "CHARGE"
else:
deye_mode = "PASSIVE"
gen_cut = bool(round(_val(z_gen[t]))) if z_gen is not None else None
hp_v = _val(hp[t])
hp_on = hp_v > rated_hp * 0.5 if rated_hp > 0 else False
cash_t = gi_w * float(s.buy_price) * wh - ge_w * float(s.sell_price) * wh
pen_t = 0.0
if gen_cut:
pen_t += max(0.0, float(s.pv_b_forecast_w)) * V2_GEN_CUTOFF_CZK_KWH * wh
results.append(
DispatchResult(
interval_start=s.interval_start,
battery_setpoint_w=batt_w,
battery_soc_target=round(_val(soc[t]) / usable * 100.0, 2),
grid_setpoint_w=grid_w,
export_limit_w=int(max_exp) if grid_w < 0 else 0,
export_mode=export_mode,
deye_physical_mode=deye_mode,
deye_gen_cutoff_enabled=gen_cut,
ev1_setpoint_w=(
round(_val(ev_direct[0][t]) + _val(ev_via_bat[0][t]))
if EV > 0 and s.ev1_connected
else None
),
ev2_setpoint_w=(
round(_val(ev_direct[1][t]) + _val(ev_via_bat[1][t]))
if EV > 1 and s.ev2_connected
else None
),
ev1_via_bat_w=round(via1_w),
ev2_via_bat_w=round(via2_w),
heat_pump_enabled=hp_on,
heat_pump_setpoint_w=int(rated_hp) if hp_on else 0,
pv_a_curtailed_w=round(_val(ca[t])),
expected_cost_czk=round(cash_t, 4),
effective_buy_price=float(s.buy_price),
effective_sell_price=float(s.sell_price),
is_predicted_price=bool(s.is_predicted_price),
cashflow_czk=round(cash_t, 4),
battery_arbitrage_czk=round(arb_czk, 4),
penalty_czk=round(pen_t, 4),
green_bonus_czk=float(getattr(s, "green_bonus_czk_per_slot", 0.0) or 0.0),
)
)
snapshot: dict[str, Any] = {
"version": planner_version or "v2-clean",
"planner_build_tag": V2_BUILD_TAG,
"inputs": {
"operating_mode": om,
"current_soc_wh": soc0,
"terminal_czk_per_wh": round(terminal, 8),
"arb_floor_wh": arb_floor,
"block_export_on_negative_sell": block_neg_sell,
"gen_cutoff_available": gen_cutoff_avail,
"slot_count": T,
"ev_sessions": sum(1 for x in ev_sessions if x is not None),
"ev_min_power_w": ev_min_w,
"ev_phases": [int(getattr(vehicles[e], "phases", 0) or 0) for e in range(EV)],
"ev_start_penalty_czk": ev_start_pen,
"masks_ignored": True,
"night_buffer_slots": sum(1 for b in nb_buffer_wh if b > 0),
"pv_risk_frontload_czk_kwh": frontload if neg_idx else 0.0,
"safety_soc_risk_factor": safety_risk,
"safety_soc_slots": sum(1 for x in safety_tgt_wh if x > 0),
"night_buffer_max_wh": round(max(nb_buffer_wh), 1) if nb_buffer_wh else 0,
},
"objective_terms": {
"cash_czk": round(float(pulp.value(cash)), 3),
"degradation_czk": round(float(pulp.value(degradation)), 3),
"extras_czk": round(float(pulp.value(extras)), 3) if not isinstance(extras, float) else 0.0,
"terminal_value_czk": round(terminal * _val(soc[T - 1]), 3),
"ev_unmet_wh": [round(_val(u), 1) for u in ev_unmet],
"ev_opp_wh": [round(_val(o), 1) for o, _v in ev_opp],
"ev_starts": [
int(round(sum(_val(ev_start[e][t]) for t in range(T))))
if ev_start[e] is not None
else 0
for e in range(EV)
],
},
"solver_duration_ms": duration_ms,
"solver_status": status_str,
}
return results, duration_ms, snapshot

View File

@@ -0,0 +1,140 @@
# backend/services/planning/types.py
#
# EMS plánovač datové typy a čisté časové utility
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from zoneinfo import ZoneInfo
from services.planning.constants import _PRAGUE_TZ
class PlannerSolverError(RuntimeError):
"""Solver selhal po vyčerpání retry řetězce (typicky Infeasible)."""
def __init__(
self,
solver_status: str,
*,
relax_chain: list[str] | None = None,
) -> None:
self.solver_status = solver_status
self.relax_chain = list(relax_chain or [])
super().__init__(f"Solver: {solver_status}")
def _timestamptz_from_db(val: object) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
def _slot_float_nullable(d: dict[str, Any], key: str) -> float | None:
v = d.get(key)
if v is None:
return None
return float(v)
def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
"""DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So."""
dt = interval_start
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
loc = dt.astimezone(_PRAGUE_TZ)
return (loc.weekday() + 1) % 7, loc.hour
@dataclass
class PlanningSlot:
interval_start: datetime
buy_price: float # Kč/kWh
sell_price: float # Kč/kWh
pv_a_forecast_w: int # W pole A (řiditelné)
pv_b_forecast_w: int # W pole B (zelený bonus, pevné)
load_baseline_w: int # W predikce bazální spotřeby
ev1_connected: bool
ev2_connected: bool
is_predicted_price: bool = False
allow_charge: bool = True
allow_discharge_export: bool = True
#: Měkké LP vstupy z `ems.fn_load_planning_slots_full` (mimo masky allow_*).
night_baseload_target_wh: float | None = None
night_baseload_buffer_wh: float | None = None
safety_soc_target_wh: float | None = None
future_avoided_buy_czk_kwh: float | None = None
future_sell_opportunity_czk_kwh: float | None = None
is_daytime_pv_surplus_slot: bool = False
#: Vážená nákupní / opportunity cena zásoby před prvním exportním oknem (SQL odhad z masek).
charge_acquisition_buy_czk_kwh: float | None = None
charge_acquisition_cutoff_at: datetime | None = None
min_buy_before_cutoff_czk_kwh: float | None = None
pv_charge_wh_ahead: float | None = None
neg_buy_wh_ahead: float | None = None
grid_charge_suppressed_reason: str | None = None
charge_target_wh: float | None = None
pre_window_wh: float | None = None
in_window_wh: float | None = None
charge_slot_wh: float | None = None
charge_cum_wh: float | None = None
charge_layer: str | None = None
charge_slot_reason: str | None = None
#: Pomocny atribut pro green_bonus v planning_interval (Kc/slot); lite default 0.
green_bonus_czk_per_slot: float = 0.0
SOC_MIN_RELAX_LOOKAHEAD_SLOTS = 144
@dataclass
class DispatchResult:
interval_start: datetime
battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení
battery_soc_target: float # % SoC na konci intervalu
grid_setpoint_w: int # kladné = import, záporné = export
export_limit_w: int # tvrdý limit exportu do sítě; 0 = bez exportu
export_mode: str # NONE / PV_SURPLUS / BATTERY_SELL
#: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE).
#: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu.
deye_physical_mode: str
#: True = v daném slotu odpojit GEN port (MI export cutoff) přes reg 178 bits01 (0-based; v UI často jako "register 179").
#: None = lokalita tuto funkci nemá / nepoužívá.
deye_gen_cutoff_enabled: bool | None
ev1_setpoint_w: Optional[int]
ev2_setpoint_w: Optional[int]
ev1_via_bat_w: int
ev2_via_bat_w: int
heat_pump_enabled: bool
heat_pump_setpoint_w: int
pv_a_curtailed_w: int
expected_cost_czk: float
effective_buy_price: float
effective_sell_price: float
is_predicted_price: bool # shodné s PlanningSlot (chybí OTE v efektivní ceně → fn_get_predicted_price)
cashflow_czk: float
battery_arbitrage_czk: float
penalty_czk: float
green_bonus_czk: float
def _prague_calendar_date(slot: PlanningSlot):
dt = slot.interval_start
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(ZoneInfo("Europe/Prague")).date()
def _prague_hour(slot: PlanningSlot) -> int:
dt = slot.interval_start
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(ZoneInfo("Europe/Prague")).hour
def _parse_json_dt(val: object) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val if val.tzinfo else val.replace(tzinfo=timezone.utc)
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
def _current_slot_start(dt: datetime) -> datetime:
"""Zaokrouhlí čas dolů na začátek aktuálního 15min slotu."""
minute = (dt.minute // 15) * 15
return dt.replace(minute=minute, second=0, microsecond=0)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
"""
Shelly Gen2+ RPC klient (HTTP, httpx) — Switch.GetStatus / Switch.Set.
Záměrně POUZE Gen2 RPC (`/rpc/<Method>?...`). Gen1 REST (`/relay/0?turn=on`)
nepodporujeme — všechna nasazovaná relé (Plus/Pro řada) mluví Gen2 a fallback
by jen maskoval chybnou konfiguraci. Viz docs/04-modules/pool-shelly.md.
Žádné retry smyčky: telemetrii volá poll cyklus každých 60 s a další pokus
zajistí sám; ovládání jde přes signal_service (vlastní retry + verify).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
DEFAULT_TIMEOUT_S = 5.0
@dataclass(frozen=True)
class ShellySwitchStatus:
"""Stav Switch komponenty ze Switch.GetStatus."""
output: bool
apower_w: float | None
aenergy_total_wh: float | None
def shelly_base_url(protocol: str | None, host: str, port: int | None) -> str:
"""Base URL Shelly z řádku ems.site_endpoint (protocol/host/port)."""
p = (protocol or "http").lower()
if p not in ("http", "https"):
p = "http"
prt = int(port or (443 if p == "https" else 80))
return f"{p}://{host}:{prt}"
def parse_switch_status(data: dict[str, Any]) -> ShellySwitchStatus:
"""Čistý parser odpovědi Switch.GetStatus (testovatelné bez HTTP).
Gen2: {"id":0,"output":true,"apower":745.3,"aenergy":{"total":12345.678,...},...}
`aenergy.total` je ve Wh; `apower` ve W. Obojí volitelné (ne každý model měří).
"""
if "output" not in data:
raise ValueError("Shelly Switch.GetStatus: missing 'output' (not a Gen2 RPC response?)")
output = bool(data["output"])
apower_w: float | None = None
if data.get("apower") is not None:
apower_w = float(data["apower"])
aenergy_total_wh: float | None = None
aenergy = data.get("aenergy")
if isinstance(aenergy, dict) and aenergy.get("total") is not None:
aenergy_total_wh = float(aenergy["total"])
return ShellySwitchStatus(
output=output,
apower_w=apower_w,
aenergy_total_wh=aenergy_total_wh,
)
async def get_switch_status(
base_url: str,
switch_id: int = 0,
*,
timeout: float = DEFAULT_TIMEOUT_S,
client: httpx.AsyncClient | None = None,
) -> ShellySwitchStatus:
"""GET {base}/rpc/Switch.GetStatus?id=N → ShellySwitchStatus.
`client` lze injektovat (testy, sdílený klient); jinak se vytvoří jednorázový.
"""
url = f"{base_url.rstrip('/')}/rpc/Switch.GetStatus"
params = {"id": int(switch_id)}
if client is not None:
resp = await client.get(url, params=params)
else:
async with httpx.AsyncClient(timeout=timeout) as c:
resp = await c.get(url, params=params)
resp.raise_for_status()
return parse_switch_status(resp.json())
async def set_switch(
base_url: str,
on: bool,
switch_id: int = 0,
*,
timeout: float = DEFAULT_TIMEOUT_S,
client: httpx.AsyncClient | None = None,
) -> bool | None:
"""GET {base}/rpc/Switch.Set?id=N&on=true|false. Vrátí was_on (předchozí stav), pokud ho Shelly poslalo.
Pozn.: produkční ovládání bazénu jde přes signal_service (journal + verify);
tato funkce je pro ruční zásahy / budoucí přímé použití.
"""
url = f"{base_url.rstrip('/')}/rpc/Switch.Set"
# Gen2 RPC parsuje query parametry jako JSON — bool musí být 'true'/'false'.
params = {"id": int(switch_id), "on": "true" if on else "false"}
if client is not None:
resp = await client.get(url, params=params)
else:
async with httpx.AsyncClient(timeout=timeout) as c:
resp = await c.get(url, params=params)
resp.raise_for_status()
data = resp.json()
was_on = data.get("was_on") if isinstance(data, dict) else None
return bool(was_on) if was_on is not None else None

View File

@@ -178,6 +178,10 @@ async def compute_export_ban_active(site_id: int, conn: asyncpg.Connection) -> b
if bool(pi.get("is_predicted_price")):
return True
export_mode = str(pi.get("export_mode") or "").upper()
if export_mode in ("PV_SURPLUS", "BATTERY_SELL"):
return False
sell_raw = pi.get("effective_sell_price")
grid_sp = int(pi.get("grid_setpoint_w") or 0)
if sell_raw is None:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,198 @@
"""Tesla Fleet API čtení stavu nabití vozidla (SoC) po příjezdu k wallboxu.
Zásady:
- Volat JEN při příjezdu (vehicle_data budí auto → vampire drain); žádný polling.
- Refresh token Tesla při každém použití ROTUJE → runtime hodnota žije v DB
(ems.tesla_token, fn_tesla_token_get/upsert); env TESLA_REFRESH_TOKEN je jen
prvotní seed. Access token cache ~8 h dle expires_in.
- Bez credentials (env prázdné) modul tiše nic nedělá — EV plánování běží na
defaultech z asset_vehicle.
Postup zřízení: docs/tesla-fleet-api.md.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import asyncpg
import httpx
from app.config import get_settings
from app.db_json import fetch_json
logger = logging.getLogger(__name__)
AUTH_TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token"
API_BASE = "https://fleet-api.prd.eu.vn.cloud.tesla.com"
HTTP_TIMEOUT_S = 15.0
#: rezerva před expirací access tokenu
ACCESS_EXPIRY_MARGIN_S = 120
MILES_TO_KM = 1.609344
def parse_charge_state(vehicle_data: dict[str, Any]) -> dict[str, Any] | None:
"""Parser vehicle_data → battery_level, charge_limit_soc, charging_state, odometer_km.
POZOR: Tesla API vrací odometer v MÍLÍCH → převod na km.
"""
resp = vehicle_data.get("response") or {}
cs = resp.get("charge_state") or {}
vs = resp.get("vehicle_state") or {}
level = cs.get("battery_level")
if level is None:
return None
odo_miles = vs.get("odometer")
ds = resp.get("drive_state") or {}
return {
"latitude": ds.get("latitude"),
"longitude": ds.get("longitude"),
"shift_state": ds.get("shift_state"),
"vin": resp.get("vin"),
"battery_level": int(level),
"charge_limit_soc": int(cs.get("charge_limit_soc") or 0) or None,
"charging_state": cs.get("charging_state"),
"odometer_km": round(float(odo_miles) * MILES_TO_KM, 1) if odo_miles is not None else None,
}
async def _get_access_token(db: asyncpg.Connection) -> Optional[str]:
s = get_settings()
client_id = (getattr(s, "tesla_client_id", "") or "").strip()
if not client_id:
return None
tok = await fetch_json(db, "select ems.fn_tesla_token_get()")
if not isinstance(tok, dict):
tok = {}
refresh = (tok.get("refresh_token") or "").strip()
if not refresh:
refresh = (getattr(s, "tesla_refresh_token", "") or "").strip()
if not refresh:
logger.debug("Tesla: žádný refresh token (env ani DB) — přeskočeno")
return None
access = tok.get("access_token")
exp_raw = tok.get("access_expires_at")
if access and exp_raw:
try:
exp = datetime.fromisoformat(str(exp_raw))
if exp - timedelta(seconds=ACCESS_EXPIRY_MARGIN_S) > datetime.now(timezone.utc):
return str(access)
except ValueError:
pass
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_S) as client:
r = await client.post(
AUTH_TOKEN_URL,
data={
"grant_type": "refresh_token",
"client_id": client_id,
"refresh_token": refresh,
},
)
if r.status_code >= 400:
# 400 invalid_grant = token spálený rotací NEBO ~10min výpadek po
# revokaci souhlasu (Tesla docs). Neshazovat volajícího tracebackem.
body = r.text[:300]
logger.error(
"Tesla token refresh selhal (HTTP %s): %s — pokud jsi právě "
"revokoval souhlas, počkej ~10 min; jinak obnov token dle "
"docs/tesla-fleet-api.md (deploy/tesla/reauth.sh)",
r.status_code,
body,
)
return None
data = r.json()
new_access = str(data["access_token"])
# rotace: Tesla vrací nový refresh token — starý přestává platit, ULOŽIT
new_refresh = str(data.get("refresh_token") or refresh)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=int(data.get("expires_in") or 3600))
await db.execute(
"select ems.fn_tesla_token_upsert($1::text, $2::text, $3::timestamptz)",
new_refresh,
new_access,
expires_at,
)
return new_access
async def get_charge_state(
db: asyncpg.Connection, vin: str | None
) -> dict[str, Any] | None:
"""SoC vozidla: dle VIN, nebo jediného vozidla na účtu (VIN vrací pro doplnění).
Vrací parse_charge_state dict, nebo None (bez credentials / vozidlo nenalezeno /
offline). Výjimky síťové vrstvy propadají volajícímu (hook je loguje).
"""
token = await _get_access_token(db)
if token is None:
return None
headers = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_S, headers=headers) as client:
r = await client.get(f"{API_BASE}/api/1/vehicles")
r.raise_for_status()
vehicles = (r.json().get("response") or [])
if not vehicles:
logger.warning("Tesla: účet nemá žádná vozidla")
return None
chosen = None
if vin:
chosen = next((v for v in vehicles if v.get("vin") == vin), None)
if chosen is None:
logger.warning("Tesla: VIN %s na účtu nenalezen", vin)
return None
elif len(vehicles) == 1:
chosen = vehicles[0]
else:
logger.warning(
"Tesla: %s vozidel na účtu a VIN v asset_vehicle chybí — doplň VIN",
len(vehicles),
)
return None
r = await client.get(
f"{API_BASE}/api/1/vehicles/{chosen['id']}/vehicle_data",
params={"endpoints": "charge_state;vehicle_state;location_data"},
)
if r.status_code == 408:
logger.info("Tesla: vozidlo spí / nedostupné (408) — SoC nedoplněno")
return None
r.raise_for_status()
return parse_charge_state(r.json())
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Vzdálenost dvou GPS bodů v metrech (čisté, testovatelné)."""
import math
r = 6_371_000.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
async def get_vehicle_api_state(db: asyncpg.Connection, vin: str | None) -> str | None:
"""Jen state z /vehicles (online/asleep/offline) — NIKDY nebudí auto."""
token = await _get_access_token(db)
if token is None:
return None
async with httpx.AsyncClient(
timeout=HTTP_TIMEOUT_S, headers={"Authorization": f"Bearer {token}"}
) as client:
r = await client.get(f"{API_BASE}/api/1/vehicles")
r.raise_for_status()
vehicles = r.json().get("response") or []
if vin:
v = next((x for x in vehicles if x.get("vin") == vin), None)
else:
v = vehicles[0] if len(vehicles) == 1 else None
return str(v["state"]) if v else None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"solver_error": "Infeasible",
"relax_chain": [
"strict",
"relaxed_expensive_import",
"relaxed_neg_buy_charge",
"relaxed_neg_prep_hold_only",
"relaxed_neg_prep_window",
"neg_sell_phases_fallback",
"relaxed_pos_sell_ge_block",
"relaxed_solver_masks"
]
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
"""PASSIVE + PV_SURPLUS: reg 108 sleduje charge intent (fix 2026-06-16).
bat_w>0 (plán chce nabíjet z přebytku) → 108=max (baterka nabere co zvládne, zbytek ven);
SoC u maxima + přebytek → 108=max (BMS kalibrace na 100 %); jen "prodej PV a drž baterku"
daleko od maxima (bat_w<=0) → 108=0. 109=max, 142 zůstává zero-export (1/2).
"""
from __future__ import annotations
import unittest
from services.control.setpoints import deye_battery_charge_discharge_amps
class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
def test_pv_surplus_export_zeros_charge_amps(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=-177,
grid_w=-2851,
max_charge_a=100,
max_discharge_a=90,
export_mode="PV_SURPLUS",
export_ban=False,
)
self.assertEqual(ch, 0)
self.assertEqual(dis, 90)
def test_pv_surplus_with_positive_battery_w_charges_at_max(self) -> None:
"""Fix 2026-06-16: plán chce nabíjet z přebytku (bat_w>0) → 108=max (ne 0).
Baterka nabere kolik zvládne, přebytek nad nabíjecí rychlost jde do sítě (BA81).
"""
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=5000,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
export_mode="PV_SURPLUS",
export_ban=False,
)
self.assertEqual(ch, 100)
self.assertEqual(dis, 100)
def test_pv_surplus_near_full_tops_off_for_calibration(self) -> None:
"""SoC u maxima (97 >= 100-3) + přebytek → 108=max i při bat_w<=0 (BMS kalibrace)."""
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=0,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
export_mode="PV_SURPLUS",
export_ban=False,
current_soc_pct=97.0,
max_soc_pct=100,
)
self.assertEqual(ch, 100)
def test_pv_surplus_sell_hold_far_from_full_zeros_charge(self) -> None:
"""Prodej PV a drž baterku daleko od maxima (bat_w<=0, SoC nízko) → 108=0."""
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=0,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
export_mode="PV_SURPLUS",
export_ban=False,
current_soc_pct=60.0,
max_soc_pct=100,
)
self.assertEqual(ch, 0)
self.assertEqual(dis, 100)
def test_passive_charge_without_export_mode_uses_max_108(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=5000,
grid_w=0,
max_charge_a=100,
max_discharge_a=100,
export_mode="NONE",
export_ban=False,
)
self.assertEqual(ch, 100)
self.assertEqual(dis, 100)
def test_legacy_negative_grid_infers_pv_surplus(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=0,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
export_mode=None,
export_ban=False,
)
self.assertEqual(ch, 0)
self.assertEqual(dis, 100)
def test_charge_mode_still_scales_108_from_battery_w(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="CHARGE",
self_sustain_local_use=False,
bat_w=2000,
grid_w=3000,
max_charge_a=100,
max_discharge_a=100,
)
self.assertLess(ch, 100)
self.assertGreater(ch, 0)
self.assertEqual(dis, 0)
def test_sell_skips_charge_amp_write(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="SELL",
self_sustain_local_use=False,
bat_w=-3000,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=80,
)
self.assertIsNone(ch)
self.assertEqual(dis, 80)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,153 @@
"""Exekuční pojistka exportu podle plánu (Plan 3)."""
from __future__ import annotations
import unittest
from services.control.exporter_monolith import (
ControlSetpoints,
_apply_export_plan_guard,
get_deye_mode,
)
from services.control.models import OperatingModeInfo
from services.control.setpoints import _DictRecord
def _auto_mode() -> OperatingModeInfo:
return OperatingModeInfo(
mode_code="AUTO",
battery_mode="AUTO",
grid_mode="AUTO",
ev_enabled=True,
heat_pump_enabled_def=True,
loxone_mode_value=0,
)
def _sp(**kwargs: object) -> ControlSetpoints:
base = dict(
battery_w=0,
grid_export_limit=8000,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=-8000,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=50,
deye_physical_mode="SELL",
export_mode="BATTERY_SELL",
export_ban=False,
)
base.update(kwargs)
return ControlSetpoints(**base) # type: ignore[arg-type]
class ExportPlanGuardTests(unittest.TestCase):
def test_neg_sell_forces_passive_no_export(self) -> None:
sp = _sp()
pi = _DictRecord(
{
"grid_setpoint_w": -8000,
"effective_sell_price": -0.5,
"export_mode": "NONE",
}
)
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
self.assertEqual(get_deye_mode(out), "PASSIVE")
self.assertTrue(out.export_ban)
self.assertEqual(out.grid_export_limit, 0)
self.assertGreaterEqual(out.grid_setpoint_w, 0)
self.assertEqual(out.export_mode, "NONE")
self.assertTrue(out.deye_gen_cutoff_enabled)
def test_export_mode_none_with_non_negative_grid(self) -> None:
sp = _sp(grid_setpoint_w=0, battery_w=-5000, export_mode="BATTERY_SELL")
pi = _DictRecord(
{
"grid_setpoint_w": 0,
"effective_sell_price": 2.5,
"export_mode": "NONE",
}
)
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
self.assertEqual(get_deye_mode(out), "PASSIVE")
self.assertEqual(out.battery_w, 0)
self.assertEqual(out.grid_export_limit, 0)
# Kladná vykupní: žádný tvrdý ban — MI (pole B) se NEodstavuje, 145 zůstává 1
# (BA81 2026-06-12: cutoff při sell +1.36 zahazoval výrobu mikroinvertorů).
self.assertFalse(out.export_ban)
self.assertFalse(out.deye_gen_cutoff_enabled)
def test_export_mode_none_positive_sell_respects_plan_cutoff(self) -> None:
# Plán explicitně chce cut-off (z_gen_cutoff) -> guard ho nesmí shodit.
sp = _sp(
grid_setpoint_w=0,
battery_w=2000,
export_mode="NONE",
deye_physical_mode="PASSIVE",
deye_gen_cutoff_enabled=True,
)
pi = _DictRecord(
{
"grid_setpoint_w": 0,
"effective_sell_price": 2.5,
"export_mode": "NONE",
}
)
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
self.assertTrue(out.deye_gen_cutoff_enabled)
def test_profitable_export_unchanged(self) -> None:
sp = _sp()
pi = _DictRecord(
{
"grid_setpoint_w": -8000,
"effective_sell_price": 9.5,
"export_mode": "BATTERY_SELL",
}
)
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
self.assertIs(out, sp)
self.assertEqual(get_deye_mode(out), "SELL")
def test_neg_sell_grid_charge_not_blocked(self) -> None:
# Záporný sell + IMPORT na nabití baterie (CHARGE / grid>0 & bat>0):
# guard NESMÍ překlopit na PASSIVE — jinak Deye nenabije ze sítě
# v záporných cenách (bug 2026-06-13).
sp = _sp(
grid_setpoint_w=17000,
battery_w=17000,
deye_physical_mode="CHARGE",
export_mode="NONE",
)
pi = _DictRecord(
{
"grid_setpoint_w": 17000,
"battery_setpoint_w": 17000,
"deye_physical_mode": "CHARGE",
"effective_sell_price": -1.2,
"export_mode": "NONE",
}
)
out = _apply_export_plan_guard(1, _auto_mode(), pi, sp)
self.assertIs(out, sp)
self.assertEqual(get_deye_mode(out), "CHARGE")
def test_non_auto_mode_skipped(self) -> None:
sp = _sp()
pi = _DictRecord({"effective_sell_price": -1.0, "export_mode": "NONE"})
mode = OperatingModeInfo(
mode_code="SELF_SUSTAIN",
battery_mode="PASSIVE",
grid_mode="PASSIVE",
ev_enabled=False,
heat_pump_enabled_def=False,
loxone_mode_value=1,
)
out = _apply_export_plan_guard(1, mode, pi, sp)
self.assertIs(out, sp)
if __name__ == "__main__":
unittest.main()

View File

@@ -11,6 +11,7 @@ from services.control.exporter_monolith import (
compute_pv_a_reg340_max_solar_w,
deye_reg_triggers_self_sustain_after_verify_exhaust,
)
from services.control.setpoints import plan_skips_deye_reg340_write
def _auto_mode() -> OperatingModeInfo:
@@ -51,6 +52,15 @@ class ComputePvAReg340Tests(unittest.TestCase):
def test_curtail_floor_zero(self) -> None:
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 1000, 5000), 0)
def test_min_clamp_when_positive(self) -> None:
self.assertEqual(
compute_pv_a_reg340_max_solar_w(32_000, 5000, 4600, min_w=400),
400,
)
def test_min_not_applied_when_curtail_to_zero(self) -> None:
self.assertEqual(compute_pv_a_reg340_max_solar_w(10_000, 1000, 5000, min_w=400), 0)
class BuildSetpointsReg340Tests(unittest.TestCase):
def test_with_cap_sets_pv_a_allowed(self) -> None:
@@ -102,6 +112,91 @@ class BuildSetpointsReg340Tests(unittest.TestCase):
assert sp is not None
self.assertEqual(sp.pv_a_allowed_w, 0)
def test_skipped_low_pv_forecast_with_mi_no_curtail(self) -> None:
"""BA81 úsvit: slabý forecast, bez curtail — EMS neposílá reg 340."""
sp = _build_setpoints(
_auto_mode(),
_pi_base(
pv_a_forecast_solver_w=405,
pv_b_forecast_solver_w=49,
pv_a_curtailed_w=0,
grid_setpoint_w=-100,
battery_setpoint_w=0,
export_mode="PV_SURPLUS",
export_limit_w=100,
),
pv_a_cap_w=32_000,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertIsNone(sp.pv_a_allowed_w)
def test_skipped_when_no_export_no_charge_no_curtail(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(
grid_setpoint_w=0,
battery_setpoint_w=0,
export_mode="NONE",
export_limit_w=0,
pv_a_curtailed_w=0,
),
pv_a_cap_w=10_000,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertIsNone(sp.pv_a_allowed_w)
def test_writes_reg340_when_curtail_planned(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(
grid_setpoint_w=0,
battery_setpoint_w=0,
export_mode="NONE",
pv_a_curtailed_w=3000,
),
pv_a_cap_w=10_000,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertEqual(sp.pv_a_allowed_w, 5000)
def test_writes_reg340_when_battery_charging_without_export(self) -> None:
sp = _build_setpoints(
_auto_mode(),
_pi_base(
grid_setpoint_w=0,
battery_setpoint_w=5000,
export_mode="NONE",
pv_a_curtailed_w=0,
),
pv_a_cap_w=10_000,
reg340_pv_a_control_enabled=True,
)
assert sp is not None
self.assertEqual(sp.pv_a_allowed_w, 10_000)
def test_plan_skips_helper(self) -> None:
self.assertTrue(
plan_skips_deye_reg340_write(
battery_setpoint_w=0,
grid_setpoint_w=0,
export_mode="NONE",
export_limit_w=0,
pv_a_curtailed_w=0,
)
)
self.assertFalse(
plan_skips_deye_reg340_write(
battery_setpoint_w=0,
grid_setpoint_w=-2000,
export_mode="PV_SURPLUS",
export_limit_w=2000,
pv_a_curtailed_w=0,
)
)
def test_skipped_when_reg340_control_disabled(self) -> None:
sp = _build_setpoints(
_auto_mode(),

View File

@@ -11,10 +11,15 @@ from services.control.exporter_monolith import (
_deye_reg178_verify_with_double_read,
_deye_tou_params,
_deye_tou_power_verify_match,
_deye_zero_export_amps_for_passive,
deye_mi_export_cutoff_want_enabled,
deye_reg_triggers_self_sustain_after_verify_exhaust,
get_deye_mode,
)
from services.control.models import OperatingModeInfo
from services.control.setpoints import (
_build_setpoints,
_deye_zero_export_amps_for_passive,
)
def _inv(*, min_soc: int | None = 12, reserve_soc: int | None = 20) -> InverterConfig:
@@ -110,6 +115,60 @@ class DeyeTouParamsTests(unittest.TestCase):
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
def test_mi_export_cutoff_on_export_ban_without_plan_flag(self) -> None:
self.assertTrue(
deye_mi_export_cutoff_want_enabled(
gen_microinverter_cutoff_enabled=True,
deye_gen_cutoff_enabled=False,
export_ban=True,
deye_mode="PASSIVE",
)
)
def test_mi_export_cutoff_off_when_sell_mode(self) -> None:
self.assertFalse(
deye_mi_export_cutoff_want_enabled(
gen_microinverter_cutoff_enabled=True,
deye_gen_cutoff_enabled=True,
export_ban=True,
deye_mode="SELL",
)
)
def test_mi_export_cutoff_off_without_feature_flag(self) -> None:
self.assertFalse(
deye_mi_export_cutoff_want_enabled(
gen_microinverter_cutoff_enabled=False,
deye_gen_cutoff_enabled=True,
export_ban=True,
deye_mode="PASSIVE",
)
)
def test_build_setpoints_uses_explicit_export_limit(self) -> None:
mode = OperatingModeInfo(
mode_code="AUTO",
battery_mode="AUTO",
grid_mode="AUTO",
ev_enabled=False,
heat_pump_enabled_def=False,
loxone_mode_value=1,
)
pi = {
"battery_setpoint_w": 0,
"grid_setpoint_w": -3000,
"export_limit_w": 13_500,
"export_mode": "PV_SURPLUS",
"ev1_setpoint_w": 0,
"ev2_setpoint_w": 0,
"heat_pump_enabled": False,
"battery_soc_target_pct": 50,
"effective_sell_price": 1.0,
}
sp = _build_setpoints(mode, pi)
self.assertIsNotNone(sp)
self.assertEqual(sp.grid_export_limit, 13_500)
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
sp = ControlSetpoints(
@@ -247,7 +306,7 @@ class DeyeTouParamsTests(unittest.TestCase):
def test_zero_export_amps_fve_overflow(self) -> None:
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
self.assertEqual(c, 0)
self.assertEqual(c, 100)
self.assertEqual(d, 90)
def test_zero_export_amps_import_hold_discharge(self) -> None:

View File

@@ -0,0 +1,164 @@
"""Discord bot — čisté helpery (custom_id, výběry → patch, deadline z voleb),
bez sítě/discord lib."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from services.discord_bot import (
action_to_patch,
choice_label,
departure_choice_to_deadline,
parse_custom_id,
select_to_patch,
)
_PRAGUE = ZoneInfo("Europe/Prague")
# 2026-06-12 je pátek; 10:00 UTC = 12:00 Europe/Prague (CEST)
_NOW = datetime(2026, 6, 12, 10, 0, tzinfo=timezone.utc)
def _prague(dt: datetime) -> str:
return dt.astimezone(_PRAGUE).strftime("%Y-%m-%d %H:%M")
class ParseCustomIdTests(unittest.TestCase):
def test_valid_selects(self) -> None:
self.assertEqual(
parse_custom_id("ev:2:ev-charger-1:dep"), (2, "ev-charger-1", "dep")
)
self.assertEqual(
parse_custom_id("ev:2:ev-charger-1:tgt"), (2, "ev-charger-1", "tgt")
)
def test_valid_legacy_buttons(self) -> None:
self.assertEqual(
parse_custom_id("ev:2:ev-charger-1:h2"), (2, "ev-charger-1", "h2")
)
def test_invalid(self) -> None:
for bad in ("", "ev:2:x:jump", "foo:1:c:h2", "ev:abc:c:h2", "ev:1:c:dep:x"):
self.assertIsNone(parse_custom_id(bad))
class DepartureChoiceTests(unittest.TestCase):
"""Absolutní deadline z výběru „Kdy odjíždíš?" (Europe/Prague)."""
def test_h2(self) -> None:
dl = departure_choice_to_deadline("h2", now=_NOW)
self.assertEqual(_prague(dl), "2026-06-12 14:00")
def test_h4(self) -> None:
dl = departure_choice_to_deadline("h4", now=_NOW)
self.assertEqual(_prague(dl), "2026-06-12 16:00")
def test_today18_before_18(self) -> None:
dl = departure_choice_to_deadline("today18", now=_NOW) # 12:00 Prague
self.assertEqual(_prague(dl), "2026-06-12 18:00")
def test_today18_after_18_rolls_to_next_day(self) -> None:
late = datetime(2026, 6, 12, 17, 30, tzinfo=timezone.utc) # 19:30 Prague
dl = departure_choice_to_deadline("today18", now=late)
self.assertEqual(_prague(dl), "2026-06-13 18:00")
def test_tomorrow7_crosses_midnight(self) -> None:
# 23:30 Prague v pátek → zítra (sobota) 07:00, tj. +7,5 h
late = datetime(2026, 6, 12, 21, 30, tzinfo=timezone.utc)
dl = departure_choice_to_deadline("tomorrow7", now=late)
self.assertEqual(_prague(dl), "2026-06-13 07:00")
def test_tomorrow12(self) -> None:
dl = departure_choice_to_deadline("tomorrow12", now=_NOW)
self.assertEqual(_prague(dl), "2026-06-13 12:00")
def test_monday7_from_friday_allows_over_48h(self) -> None:
# explicitní volba smí přes 48h limit fn_ev_session_defaults
dl = departure_choice_to_deadline("monday7", now=_NOW) # pátek 12:00
self.assertEqual(_prague(dl), "2026-06-15 07:00")
self.assertGreater((dl - _NOW).total_seconds(), 48 * 3600)
def test_monday7_on_monday_before_7_is_today(self) -> None:
mon_early = datetime(2026, 6, 15, 3, 0, tzinfo=timezone.utc) # po 05:00
dl = departure_choice_to_deadline("monday7", now=mon_early)
self.assertEqual(_prague(dl), "2026-06-15 07:00")
def test_monday7_on_monday_after_7_is_next_week(self) -> None:
mon_late = datetime(2026, 6, 15, 8, 0, tzinfo=timezone.utc) # po 10:00
dl = departure_choice_to_deadline("monday7", now=mon_late)
self.assertEqual(_prague(dl), "2026-06-22 07:00")
def test_unknown_choice_raises(self) -> None:
with self.assertRaises(ValueError):
departure_choice_to_deadline("never", now=_NOW)
class SelectPatchTests(unittest.TestCase):
"""Mapování výběrů na patch payload pro fn_ev_session_apply_patch."""
def test_dep_patches_only_deadline(self) -> None:
p = select_to_patch("dep", "today18", now=_NOW, soc_at_connect=55.0)
self.assertEqual(set(p), {"target_deadline"})
self.assertIn("2026-06-12T18:00", p["target_deadline"])
self.assertIn("+02:00", p["target_deadline"]) # Europe/Prague (CEST)
def test_tgt_patches_only_target(self) -> None:
for value, expected in (("30", 30.0), ("50", 50.0), ("70", 70.0), ("100", 100.0)):
p = select_to_patch("tgt", value, now=_NOW, soc_at_connect=55.0)
self.assertEqual(p, {"target_soc_pct": expected})
def test_tgt_stop_targets_connect_soc(self) -> None:
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=42.5)
self.assertEqual(p, {"target_soc_pct": 42.5})
def test_tgt_stop_without_soc(self) -> None:
p = select_to_patch("tgt", "stop", now=_NOW, soc_at_connect=None)
self.assertEqual(p, {"target_soc_pct": 0.0})
def test_unknown_kind_raises(self) -> None:
with self.assertRaises(ValueError):
select_to_patch("foo", "30", now=_NOW, soc_at_connect=None)
def test_labels(self) -> None:
self.assertEqual(choice_label("dep", "monday7"), "odjezd pondělí ráno 7:00")
self.assertEqual(choice_label("tgt", "70"), "cíl 70 %")
self.assertEqual(choice_label("tgt", "stop"), "nenabíjet")
class LegacyActionPatchTests(unittest.TestCase):
"""Legacy tlačítka starších zpráv (h2/h4/morning/full/stop)."""
def _patch(self, action: str, **kw):
return action_to_patch(
action,
now=_NOW,
soc_at_connect=kw.get("soc", 55.0),
default_deadline_hour=kw.get("hour", 7),
)
def test_h2_deadline(self) -> None:
p = self._patch("h2")
self.assertIn("2026-06-12T12:00", p["target_deadline"])
def test_morning_next_occurrence(self) -> None:
p = self._patch("morning", hour=7)
# 12:00 Prague > 7:00 → zítra 7:00 Prague
self.assertIn("2026-06-13T07:00", p["target_deadline"])
def test_morning_today_if_before(self) -> None:
early = datetime(2026, 6, 12, 2, 0, tzinfo=timezone.utc) # 4:00 Prague
p = action_to_patch("morning", now=early, soc_at_connect=50, default_deadline_hour=7)
self.assertIn("2026-06-12T07:00", p["target_deadline"])
def test_full(self) -> None:
p = self._patch("full")
self.assertEqual(p["target_soc_pct"], 100)
def test_stop_targets_connect_soc(self) -> None:
p = self._patch("stop", soc=42.5)
self.assertEqual(p["target_soc_pct"], 42.5)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,41 @@
"""EV presence — čisté helpery (haversine, přechody)."""
from __future__ import annotations
import unittest
from services.telemetry_collector import ev_presence_transition
from services.tesla_client import haversine_m
class HaversineTests(unittest.TestCase):
def test_zero_distance(self) -> None:
self.assertAlmostEqual(haversine_m(49.2445, 17.4070, 49.2445, 17.4070), 0.0, places=2)
def test_known_distance(self) -> None:
# ~111 km na 1° zeměpisné šířky
d = haversine_m(49.0, 17.0, 50.0, 17.0)
self.assertAlmostEqual(d, 111_195, delta=300)
def test_geofence_scale(self) -> None:
# ~100 m posun (0.0009° lat)
d = haversine_m(49.24457, 17.407054, 49.24547, 17.407054)
self.assertTrue(80 < d < 120, d)
class TransitionTests(unittest.TestCase):
def test_arrived(self) -> None:
self.assertEqual(ev_presence_transition(False, True), "arrived")
def test_left(self) -> None:
self.assertEqual(ev_presence_transition(True, False), "left")
def test_none_cases(self) -> None:
self.assertIsNone(ev_presence_transition(None, True))
self.assertIsNone(ev_presence_transition(True, None))
self.assertIsNone(ev_presence_transition(True, True))
self.assertIsNone(ev_presence_transition(False, False))
if __name__ == "__main__":
unittest.main()

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

@@ -0,0 +1,212 @@
"""TeltoCharge zápis: reg 15 (amps) VŽDY, watchdog 19/20 write-on-change.
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
from unittest.mock import AsyncMock, patch
import services.control.modbus_journal as journal
from services.control.modbus_journal import _drop_registers_matching_last_verified
from services.control.models import ControlSetpoints
from services.control.outputs import (
TELTO_REG_AMPS_TO_USE,
TELTO_REG_COMM_TIMEOUT_S,
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,
)
#: Stav zařízení po prvním úspěšném exportu s 0 A (klid, auto nepřipojené).
_STEADY_STATE_0A = {
TELTO_REG_AMPS_TO_USE: 0,
TELTO_REG_COMM_TIMEOUT_S: TELTO_WATCHDOG_TIMEOUT_S,
TELTO_REG_FAILSAFE_CURRENT_A: TELTO_WATCHDOG_FAILSAFE_A,
}
def _setpoints(ev1_a: int = 0) -> ControlSetpoints:
return ControlSetpoints(
battery_w=None,
grid_export_limit=0,
ev1_current_a=ev1_a,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=0,
ev1_power_w=0,
ev2_power_w=0,
)
class TeltoSetpointRegistersTests(unittest.TestCase):
def test_triple_for_zero_amps(self) -> None:
regs = _telto_setpoint_registers(0)
self.assertEqual(
[(r, v) for r, _, v in regs],
[(15, 0), (19, 300), (20, 8)],
)
def test_amps_below_six_coerced_to_zero_and_clamped_to_32(self) -> None:
self.assertEqual(_telto_setpoint_registers(5)[0][2], 0)
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_watchdog_steady_state_drops_19_20(self) -> None:
_, watchdog = _split_amps_and_watchdog(_telto_setpoint_registers(0))
out, skipped = _drop_registers_matching_last_verified(
watchdog, _STEADY_STATE_0A
)
self.assertEqual(out, [])
self.assertEqual(skipped, [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, 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]:
return [self.row]
async def fetchrow(self, query: str, *args: object) -> dict:
return self.row
async def fetchval(self, query: str, *args: object) -> None:
raise AssertionError(f"unexpected fetchval: {query}")
class WriteEvSetpointsTests(unittest.IsolatedAsyncioTestCase):
async def _run(
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)
with (
patch.object(
journal,
"_fetch_device_state_registers",
AsyncMock(return_value=device_state),
),
patch.object(journal, "create_modbus_commands", create),
patch.object(journal, "execute_modbus_commands", execute),
):
await write_ev_setpoints(1, _setpoints(ev1_a), db or _FakeDB()) # type: ignore[arg-type]
return create, execute
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_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)
create.assert_awaited_once()
registers = create.await_args.args[8]
self.assertEqual([(r, v) for r, _, v in registers], [(15, 16)])
execute.assert_awaited_once()
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 WriteEvArrivalHoldTests(unittest.IsolatedAsyncioTestCase):
async def _run(
self, device_state: dict[int, int]
) -> tuple[bool, AsyncMock, AsyncMock]:
create = AsyncMock(return_value=[1])
execute = AsyncMock(return_value=True)
with (
patch.object(
journal,
"_fetch_device_state_registers",
AsyncMock(return_value=device_state),
),
patch.object(journal, "create_modbus_commands", create),
patch.object(journal, "execute_modbus_commands", execute),
):
ok = await write_ev_arrival_hold(1, "ev-charger-1", _FakeDB()) # type: ignore[arg-type]
return ok, create, execute
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)
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_amps_and_watchdog_when_device_drifted(self) -> None:
ok, create, execute = await self._run(
{
TELTO_REG_AMPS_TO_USE: 16,
TELTO_REG_COMM_TIMEOUT_S: TELTO_WATCHDOG_TIMEOUT_S,
TELTO_REG_FAILSAFE_CURRENT_A: TELTO_WATCHDOG_FAILSAFE_A,
}
)
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()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,35 @@
"""HARD LIMIT exportu (CLAUDE.md §4.19): reg 143 nikdy nad limit ulice.
Pokuta v řádu desítek tisíc Kč za každou kW překročení rezervovaného
exportního výkonu na fakturačním elektroměru. Terminálový limit (reg 143)
nesmí přesáhnout max_export_power_w za žádných okolností — žádný
feed-forward o měřenou spotřebu mezi střídačem a CT.
"""
from __future__ import annotations
import unittest
from services.control.setpoints import _deye_reg143_export_w
class ExportHardLimitTests(unittest.TestCase):
def test_reg143_never_exceeds_street_limit(self) -> None:
street_limit = 13_500
self.assertLessEqual(
_deye_reg143_export_w(False, street_limit), street_limit
)
def test_no_export_is_zero(self) -> None:
self.assertEqual(_deye_reg143_export_w(True, 13_500), 0)
def test_plan_export_limit_caps_not_raises(self) -> None:
# vzor z write_inverter_setpoints: export_lim = min(hw, plan) — plán
# smí limit jen SNÍŽIT, nikdy zvýšit
hw = _deye_reg143_export_w(False, 13_500)
plan_limit = 20_000
self.assertLessEqual(min(hw, plan_limit), 13_500)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,205 @@
"""
Fáze 0 golden replay gate plánovače (bez DB).
Pro každou fixture v tests/golden/fixtures/ (kompletní vstupy solveru zmrazené
z reálné DB skriptem scripts/harness/extract_fixtures.py) spustí
solve_dispatch_two_pass a porovná normalizovaný výstup s golden snapshotem
v tests/golden/snapshots/.
Účel: regresní brána pro dekompozici planning_engine.py — identity refactor
musí držet výstupy bit-perfektně (floaty zaokrouhleny na 4 d.m.).
Regenerace snapshotů (vědomá změna chování):
GOLDEN_UPDATE=1 python3 -m pytest tests/test_golden_replay.py -q
Replay jde STEJNOU cestou jako produkce: _load_site_context + _load_slots nad
fixture stubem DB → žádná duplikace mapování DB → objekty.
"""
from __future__ import annotations
import asyncio
import json
import os
import unittest
from datetime import datetime
from pathlib import Path
from services import planning_engine as pe
GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
FIXTURES_DIR = GOLDEN_DIR / "fixtures"
SNAPSHOTS_DIR = GOLDEN_DIR / "snapshots"
_DT_SLOT_KEYS = ("interval_start", "charge_acquisition_cutoff_at")
class _FixtureDB:
"""Stub asyncpg connection: vrací zmrazený context a sloty z fixture."""
def __init__(self, fixture: dict):
self._fixture = fixture
async def fetchval(self, query: str, *args):
assert "fn_planning_site_context" in query, f"Nečekaný fetchval: {query!r}"
return json.dumps(self._fixture["context_json"])
async def fetch(self, query: str, *args):
assert "fn_load_planning_slots_full" in query, f"Nečekaný fetch: {query!r}"
rows: list[dict] = []
for raw in self._fixture["slot_rows"]:
d = dict(raw)
for key in _DT_SLOT_KEYS:
if d.get(key):
d[key] = datetime.fromisoformat(d[key])
rows.append(d)
return rows
def _round(val: float, places: int = 4) -> float:
out = round(float(val), places)
return 0.0 if out == 0.0 else out # normalizace -0.0
def _normalize_results(results: list) -> dict:
rows = []
for r in results:
rows.append(
{
"interval_start": r.interval_start.isoformat(),
"battery_setpoint_w": int(r.battery_setpoint_w),
"battery_soc_target": _round(r.battery_soc_target, 2),
"grid_setpoint_w": int(r.grid_setpoint_w),
"export_limit_w": int(r.export_limit_w),
"export_mode": r.export_mode,
"deye_physical_mode": r.deye_physical_mode,
"deye_gen_cutoff_enabled": r.deye_gen_cutoff_enabled,
"ev1_setpoint_w": r.ev1_setpoint_w,
"ev2_setpoint_w": r.ev2_setpoint_w,
"ev1_via_bat_w": int(r.ev1_via_bat_w),
"ev2_via_bat_w": int(r.ev2_via_bat_w),
"heat_pump_enabled": bool(r.heat_pump_enabled),
"heat_pump_setpoint_w": int(r.heat_pump_setpoint_w),
"pv_a_curtailed_w": int(r.pv_a_curtailed_w),
"expected_cost_czk": _round(r.expected_cost_czk),
"cashflow_czk": _round(r.cashflow_czk),
"battery_arbitrage_czk": _round(r.battery_arbitrage_czk),
"penalty_czk": _round(r.penalty_czk),
"green_bonus_czk": _round(r.green_bonus_czk),
}
)
totals = {
"slots": len(rows),
"expected_cost_czk": _round(sum(r["expected_cost_czk"] for r in rows), 3),
"cashflow_czk": _round(sum(r["cashflow_czk"] for r in rows), 3),
"penalty_czk": _round(sum(r["penalty_czk"] for r in rows), 3),
"grid_import_slots": sum(1 for r in rows if r["grid_setpoint_w"] > 0),
"grid_export_slots": sum(1 for r in rows if r["grid_setpoint_w"] < 0),
"curtail_slots": sum(1 for r in rows if r["pv_a_curtailed_w"] > 0),
}
return {"totals": totals, "slots": rows}
def _replay_fixture(fixture: dict) -> dict:
async def _run() -> dict:
db = _FixtureDB(fixture)
meta = fixture["meta"]
(
battery,
heat_pump,
grid,
vehicles,
ev_sessions,
soc_wh,
tuv_temp,
operating_mode,
tuv_stats,
) = await pe._load_site_context(int(meta["site_id"]), db)
slots = await pe._load_slots(
int(meta["site_id"]),
datetime.fromisoformat(meta["window_from"]),
datetime.fromisoformat(meta["window_to"]),
db,
soc_wh=soc_wh,
)
try:
results, _ms, _snap = pe.solve_dispatch_two_pass(
slots,
battery,
heat_pump,
grid,
ev_sessions,
vehicles,
soc_wh,
tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=operating_mode or "AUTO",
planner_version=pe._planner_engine_version(),
)
except pe.PlannerSolverError as exc:
# Selhání solveru je taky chování k zafixování (např. home-01 2026-05-01:
# Infeasible po celém relax řetězci). Až ho Fáze 2/3 opraví, golden diff
# to zviditelní a snapshot se vědomě zregeneruje.
return {
"solver_error": exc.solver_status,
"relax_chain": list(exc.relax_chain),
}
return _normalize_results(results)
return asyncio.run(_run())
def _fixture_paths() -> list[Path]:
return sorted(FIXTURES_DIR.glob("*.json"))
class GoldenReplayTests(unittest.TestCase):
maxDiff = None
def test_fixtures_exist(self) -> None:
self.assertTrue(
_fixture_paths(),
f"Žádné fixtures v {FIXTURES_DIR} spusť scripts/harness/extract_fixtures.py",
)
def _make_test(path: Path):
def test(self: GoldenReplayTests) -> None:
fixture = json.loads(path.read_text(encoding="utf-8"))
actual = _replay_fixture(fixture)
snap_path = SNAPSHOTS_DIR / path.name
if os.environ.get("GOLDEN_UPDATE") == "1":
SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
snap_path.write_text(
json.dumps(actual, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
)
return
self.assertTrue(
snap_path.exists(),
f"Chybí snapshot {snap_path.name} vygeneruj přes GOLDEN_UPDATE=1",
)
expected = json.loads(snap_path.read_text(encoding="utf-8"))
if "solver_error" in expected or "solver_error" in actual:
self.assertEqual(expected, actual, f"{path.name}: změna výsledku/selhání solveru")
return
self.assertEqual(
expected["totals"],
actual["totals"],
f"{path.name}: změna agregátů plánu (totals)",
)
self.assertEqual(
expected["slots"],
actual["slots"],
f"{path.name}: změna plánu per slot",
)
return test
for _path in _fixture_paths():
_name = "test_golden_" + _path.stem.replace("-", "_").replace(".", "_")
setattr(GoldenReplayTests, _name, _make_test(_path))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,234 @@
"""execute_modbus_commands: žádná cesta nesmí nechat příkaz 'pending'.
Regrese na živý incident home-01 (TeltoCharge 172.16.1.16): zápisová trojice
(15, 1920) buď skončila 'failed' s prázdným error_msg (str(TimeoutError())
== ''), nebo zůstala trvale 'pending' (export visel bez limitu na flock brány
obsazené pollingem mrtvého unit_id; výjimka mimo retry cyklus stav neuložila).
Testy: (1) error_msg nikdy prázdný; (2) GatewayLockTimeout → failed
s 'gateway lock timeout'; (3) CancelledError / chyba DB → safety net označí
zbylé příkazy failed a výjimku propaguje; (4) flock s timeoutem v
modbus_client; (5) backoff pollingu nedosažitelného wallboxu.
"""
import asyncio
import fcntl
import os
import tempfile
import unittest
from unittest.mock import AsyncMock, patch
import services.control.modbus_journal as journal
import services.modbus_client as mc
import services.telemetry_collector as tc
from services.control.modbus_journal import (
_modbus_error_text,
execute_modbus_commands,
)
from services.modbus_client import GatewayLockTimeout
def _cmd_row(cid: int, reg: int, val: int = 0) -> dict:
return {
"id": cid,
"register": reg,
"value_to_write": val,
"device_host": "172.16.1.16",
"device_port": 502,
"device_unit_id": 1,
"asset_code": "ev-charger-1",
}
class _JournalDB:
"""In-memory journal — sleduje status a error_msg per command id."""
def __init__(self, rows: list[dict], fail_written_update: bool = False) -> None:
self.rows = {r["id"]: dict(r) for r in rows}
self.status = {r["id"]: "pending" for r in rows}
self.error_msg: dict[int, str | None] = {r["id"]: None for r in rows}
self.fail_written_update = fail_written_update
async def fetchrow(self, query: str, cid: int) -> dict | None:
return self.rows.get(cid)
async def execute(self, query: str, *args: object) -> None:
if "status='written'" in query:
if self.fail_written_update:
raise RuntimeError("db connection lost")
_val, cid = args
self.status[int(cid)] = "written" # type: ignore[arg-type]
self.error_msg[int(cid)] = None # type: ignore[arg-type]
elif "status='failed'" in query:
msg, cid = args
self.status[int(cid)] = "failed" # type: ignore[arg-type]
self.error_msg[int(cid)] = str(msg) # type: ignore[arg-type]
else:
raise AssertionError(f"unexpected execute: {query}")
def _fake_client(write_exc: BaseException | None = None) -> AsyncMock:
client = AsyncMock()
if write_exc is not None:
client.write_registers.side_effect = write_exc
client.force_disconnect = AsyncMock()
return client
class ErrorTextTests(unittest.TestCase):
def test_empty_str_exception_falls_back_to_repr(self) -> None:
self.assertEqual(_modbus_error_text(TimeoutError()), "TimeoutError()")
def test_nonempty_str_kept(self) -> None:
self.assertEqual(_modbus_error_text(OSError("boom")), "boom")
class ExecuteFailsafeTests(unittest.IsolatedAsyncioTestCase):
async def _run(
self,
db: _JournalDB,
client: AsyncMock,
ids: list[int],
) -> bool:
with (
patch.object(journal, "get_modbus_client", AsyncMock(return_value=client)),
patch.object(journal.asyncio, "sleep", AsyncMock()),
):
return await execute_modbus_commands(ids, db) # type: ignore[arg-type]
async def test_timeout_with_empty_str_marks_failed_with_nonempty_msg(self) -> None:
db = _JournalDB([_cmd_row(1, 15), _cmd_row(2, 19), _cmd_row(3, 20)])
ok = await self._run(db, _fake_client(TimeoutError()), [1, 2, 3])
self.assertFalse(ok)
self.assertEqual(set(db.status.values()), {"failed"})
for msg in db.error_msg.values():
self.assertTrue(msg) # nikdy NULL/prázdný
async def test_gateway_lock_timeout_marks_failed_with_reason(self) -> None:
db = _JournalDB([_cmd_row(1, 15)])
exc = GatewayLockTimeout("gateway lock timeout 172.16.1.16:502 after 20s")
ok = await self._run(db, _fake_client(exc), [1])
self.assertFalse(ok)
self.assertEqual(db.status[1], "failed")
self.assertIn("gateway lock timeout", db.error_msg[1] or "")
async def test_cancelled_error_marks_failed_and_reraises(self) -> None:
db = _JournalDB([_cmd_row(1, 15), _cmd_row(2, 19), _cmd_row(3, 20)])
with self.assertRaises(asyncio.CancelledError):
await self._run(db, _fake_client(asyncio.CancelledError()), [1, 2, 3])
self.assertEqual(set(db.status.values()), {"failed"})
for msg in db.error_msg.values():
self.assertIn("execute aborted", msg or "")
async def test_db_failure_in_written_update_marks_rest_failed(self) -> None:
db = _JournalDB([_cmd_row(1, 15), _cmd_row(2, 19)], fail_written_update=True)
with self.assertRaises(RuntimeError):
await self._run(db, _fake_client(), [1, 2])
self.assertEqual(set(db.status.values()), {"failed"})
self.assertIn("db connection lost", db.error_msg[1] or "")
async def test_force_disconnect_failure_does_not_leave_pending(self) -> None:
db = _JournalDB([_cmd_row(1, 15)])
client = _fake_client(OSError("write boom"))
client.force_disconnect.side_effect = OSError("disconnect boom")
ok = await self._run(db, client, [1])
self.assertFalse(ok)
self.assertEqual(db.status[1], "failed")
self.assertIn("write boom", db.error_msg[1] or "")
async def test_success_path_still_written(self) -> None:
db = _JournalDB([_cmd_row(1, 15), _cmd_row(2, 19), _cmd_row(3, 20)])
ok = await self._run(db, _fake_client(), [1, 2, 3])
self.assertTrue(ok)
self.assertEqual(set(db.status.values()), {"written"})
class GatewayFlockTimeoutTests(unittest.IsolatedAsyncioTestCase):
async def test_lock_timeout_raises_gateway_lock_timeout(self) -> None:
with tempfile.TemporaryDirectory() as d, patch.dict(
os.environ,
{"EMS_MODBUS_LOCK_DIR": d, "EMS_MODBUS_FLOCK_TIMEOUT_S": "0.3"},
):
path = mc._gateway_lock_path("10.99.99.99", 502)
path.parent.mkdir(parents=True, exist_ok=True)
holder = open(path, "a+b") # noqa: SIM115
fcntl.flock(holder.fileno(), fcntl.LOCK_EX)
try:
with self.assertRaises(GatewayLockTimeout) as ctx:
async with mc._gateway_exclusive("10.99.99.99", 502):
pass
self.assertIn("gateway lock timeout", str(ctx.exception))
finally:
fcntl.flock(holder.fileno(), fcntl.LOCK_UN)
holder.close()
async def test_lock_acquired_when_free(self) -> None:
with tempfile.TemporaryDirectory() as d, patch.dict(
os.environ, {"EMS_MODBUS_LOCK_DIR": d}
):
async with mc._gateway_exclusive("10.99.99.98", 502):
pass # bez výjimky
class EvPollBackoffTests(unittest.TestCase):
KEY = ("172.16.1.16", 502, 2)
def setUp(self) -> None:
tc._EV_POLL_FAIL_STREAK.clear()
tc._EV_POLL_NEXT_ATTEMPT.clear()
def test_below_threshold_never_skips(self) -> None:
tc._ev_poll_record_failure(self.KEY, 100.0)
tc._ev_poll_record_failure(self.KEY, 160.0)
self.assertFalse(tc._ev_poll_should_skip(self.KEY, 220.0))
def test_skips_after_threshold_until_backoff_elapses(self) -> None:
for t in (100.0, 160.0, 220.0):
tc._ev_poll_record_failure(self.KEY, t)
self.assertTrue(tc._ev_poll_should_skip(self.KEY, 221.0))
self.assertTrue(
tc._ev_poll_should_skip(self.KEY, 220.0 + tc.EV_POLL_BACKOFF_S - 1)
)
self.assertFalse(
tc._ev_poll_should_skip(self.KEY, 220.0 + tc.EV_POLL_BACKOFF_S + 1)
)
def test_success_resets_streak(self) -> None:
for t in (100.0, 160.0, 220.0):
tc._ev_poll_record_failure(self.KEY, t)
tc._ev_poll_record_success(self.KEY)
self.assertFalse(tc._ev_poll_should_skip(self.KEY, 221.0))
class _PollDB:
"""Jen řádek chargeru pro poll_ev_chargers (failure path se dál nedotkne DB)."""
def __init__(self) -> None:
self.row = {
"id": 7,
"code": "ev-charger-2",
"host": "172.16.1.16",
"port": 502,
"unit_id": 2,
}
async def fetch(self, query: str, *args: object) -> list[dict]:
return [self.row]
class PollEvChargersBackoffIntegrationTests(unittest.IsolatedAsyncioTestCase):
async def test_dead_unit_stops_hitting_gateway_after_threshold(self) -> None:
tc._EV_POLL_FAIL_STREAK.clear()
tc._EV_POLL_NEXT_ATTEMPT.clear()
get_client = AsyncMock(side_effect=OSError("unit 2 unreachable"))
with patch.object(tc, "get_modbus_client", get_client):
for _ in range(tc.EV_POLL_FAIL_THRESHOLD):
await tc.poll_ev_chargers(1, _PollDB()) # type: ignore[arg-type]
self.assertEqual(get_client.await_count, tc.EV_POLL_FAIL_THRESHOLD)
# další tick uvnitř backoff okna už na bránu nesahá
await tc.poll_ev_chargers(1, _PollDB()) # type: ignore[arg-type]
self.assertEqual(get_client.await_count, tc.EV_POLL_FAIL_THRESHOLD)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
"""DispatchResult: nove ekonomicke sloupce (cashflow/arbitraz/penalty/bonus)."""
from __future__ import annotations
import unittest
from dataclasses import fields
from services.planning_engine import DispatchResult
class DispatchResultEconomicsFieldsTests(unittest.TestCase):
def test_has_new_economics_fields(self) -> None:
names = {f.name for f in fields(DispatchResult)}
for required in (
"cashflow_czk",
"battery_arbitrage_czk",
"penalty_czk",
"green_bonus_czk",
):
self.assertIn(required, names, f"DispatchResult missing field {required}")
def test_legacy_expected_cost_czk_kept(self) -> None:
names = {f.name for f in fields(DispatchResult)}
self.assertIn("expected_cost_czk", names)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,286 @@
"""Měkké safety SoC a rolling charge commitment v solve_dispatch."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.planning_engine import PlanningSlot, solve_dispatch
def _bat(**kwargs: object) -> SimpleNamespace:
base = dict(
usable_capacity_wh=20_000.0,
min_soc_wh=2000.0,
arb_floor_wh=4000.0,
reserve_soc_wh=4000.0,
soc_max_wh=19_000.0,
charge_efficiency=0.95,
discharge_efficiency=0.95,
degradation_cost_czk_kwh=0.1,
max_charge_power_w=5000,
max_discharge_power_w=5000,
planner_terminal_soc_value_factor=0.2,
planner_extreme_buy_threshold_czk_kwh=-5.0,
planner_discharge_floor_percent=None,
planner_discharge_relax_prewindow_slots=8,
planner_daytime_charge_target_enabled=True,
planner_charge_commitment_penalty_czk_kwh=0.5,
)
base.update(kwargs)
return SimpleNamespace(**base)
def _grid() -> SimpleNamespace:
return SimpleNamespace(
max_import_power_w=11_000,
max_export_power_w=11_000,
block_export_on_negative_sell=False,
deye_gen_microinverter_cutoff_enabled=False,
)
def _hp() -> SimpleNamespace:
return SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
def _slot(
t0: datetime,
idx: int,
*,
buy: float = 3.0,
sell: float = 2.5,
pv_a: int = 0,
load: int = 1500,
safety: float | None = None,
fut_buy: float | None = None,
fut_sell: float | None = None,
daytime_pv_surplus: bool = False,
) -> PlanningSlot:
return PlanningSlot(
interval_start=t0 + timedelta(minutes=15 * idx),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=0,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
safety_soc_target_wh=safety,
future_avoided_buy_czk_kwh=fut_buy,
future_sell_opportunity_czk_kwh=fut_sell,
is_daytime_pv_surplus_slot=daytime_pv_surplus,
)
class PlanningSafetyCommitmentTests(unittest.TestCase):
def test_solver_snapshot_has_version_and_masks(self) -> None:
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
slots = [_slot(t0, i, buy=2.0, sell=2.0, pv_a=6000, load=1500) for i in range(8)]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
res, _ms, snap = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=5000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
)
self.assertEqual(len(res), 8)
self.assertEqual(snap.get("version"), 1)
self.assertIn("masks", snap)
self.assertEqual(len(snap["masks"]), 8)
def test_charge_commitment_snapshot_populated(self) -> None:
"""Rolling kotva: při předchozím nabíjení z PV se do snapshotu zapíše commitment."""
t0 = datetime(2026, 5, 4, 10, 0, tzinfo=timezone.utc)
slots = [_slot(t0, i, buy=1.5, sell=1.2, pv_a=8000, load=1000) for i in range(12)]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
prev = [None] * 12
prev[0] = 4000.0
_res1, _, snap1 = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=4000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
charge_commitment_prev_w=prev,
)
self.assertTrue(snap1["chosen_slots"]["charge_commitment"])
_res2, _, snap2 = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=4000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
charge_commitment_prev_w=None,
)
self.assertEqual(snap2["chosen_slots"]["charge_commitment"], [])
def test_export_floor_uses_safety_target_in_non_high_sell_slot(self) -> None:
"""Regrese: safety target nemá tlačit jen přes objective, ale chránit export floor."""
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
# Slot 0 není high-sell (future max sell je vyšší), ale safety target je nad arb_base.
slots = [
_slot(
t0,
0,
buy=3.0,
sell=2.0,
pv_a=8000,
load=500,
safety=12_000.0,
fut_sell=6.0, # high-sell somewhere later, not this slot
daytime_pv_surplus=True,
),
_slot(
t0,
1,
buy=3.0,
sell=6.0,
pv_a=0,
load=500,
safety=12_000.0,
fut_sell=6.0,
daytime_pv_surplus=False,
),
]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
_res, _ms, snap = solve_dispatch(
slots,
_bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=8000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
)
b0 = snap["soc_bounds"][0]
self.assertEqual(b0["export_floor_reason"], "safety_export_floor")
self.assertEqual(float(b0["export_soc_floor_wh"]), 12_000.0)
self.assertFalse(bool(b0["high_sell_slot"]))
def test_export_floor_keeps_arb_base_in_high_sell_slot(self) -> None:
"""High-sell výjimka: v peak slotu nesmí safety floor blokovat arbitráž."""
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
# Slot 0 je high-sell (sell == future max), safety target je nad arb_base, ale nemá se aplikovat.
slots = [
_slot(
t0,
0,
buy=3.0,
sell=6.0,
pv_a=0,
load=500,
safety=12_000.0,
fut_sell=6.0,
daytime_pv_surplus=False,
),
_slot(
t0,
1,
buy=3.0,
sell=2.0,
pv_a=0,
load=500,
safety=12_000.0,
fut_sell=6.0,
daytime_pv_surplus=False,
),
]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
_res, _ms, snap = solve_dispatch(
slots,
_bat(arb_floor_wh=4000.0, reserve_soc_wh=4000.0, min_soc_wh=2000.0, soc_max_wh=19_000.0),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=8000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
)
b0 = snap["soc_bounds"][0]
self.assertTrue(bool(b0["high_sell_slot"]))
self.assertEqual(b0["export_floor_reason"], "arb_base")
self.assertEqual(float(b0["export_soc_floor_wh"]), 4000.0)
def test_safety_penalty_only_active_in_daytime_pv_surplus_slots(self) -> None:
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
slots = [
_slot(
t0,
0,
buy=3.0,
sell=2.0,
pv_a=8000,
load=500,
safety=12_000.0,
fut_sell=6.0,
daytime_pv_surplus=True,
),
_slot(
t0,
1,
buy=3.0,
sell=2.0,
pv_a=0,
load=500,
safety=12_000.0,
fut_sell=6.0,
daytime_pv_surplus=False,
),
]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
_res, _ms, snap = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=8000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
)
t0o = snap["objective_terms"][0]
t1o = snap["objective_terms"][1]
self.assertTrue(bool(t0o["safety_penalty_active"]))
self.assertGreater(float(t0o["safety_deficit_penalty_czk_per_wh"]), 0.0)
self.assertFalse(bool(t1o["safety_penalty_active"]))
self.assertEqual(float(t1o["safety_deficit_penalty_czk_per_wh"]), 0.0)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,157 @@
"""Shelly Gen2 RPC klient — parser Switch.GetStatus a stavba RPC volání (mock httpx)."""
from __future__ import annotations
import asyncio
import json
import unittest
import httpx
from services.shelly_client import (
ShellySwitchStatus,
get_switch_status,
parse_switch_status,
set_switch,
shelly_base_url,
)
class ParseSwitchStatusTests(unittest.TestCase):
def test_full_gen2_payload(self) -> None:
st = parse_switch_status(
{
"id": 0,
"source": "HTTP_in",
"output": True,
"apower": 745.3,
"voltage": 231.2,
"current": 3.25,
"aenergy": {"total": 12345.678, "by_minute": [123, 120, 118]},
"temperature": {"tC": 41.2},
}
)
self.assertEqual(
st,
ShellySwitchStatus(output=True, apower_w=745.3, aenergy_total_wh=12345.678),
)
def test_minimal_payload_without_metering(self) -> None:
# Levnější relé bez měření: jen output.
st = parse_switch_status({"id": 0, "output": False})
self.assertFalse(st.output)
self.assertIsNone(st.apower_w)
self.assertIsNone(st.aenergy_total_wh)
def test_missing_output_raises(self) -> None:
# Gen1 /relay/0 odpověď ('ison') nesmí tiše projít — podporujeme jen Gen2.
with self.assertRaises(ValueError):
parse_switch_status({"ison": True, "has_timer": False})
def test_zero_values_kept(self) -> None:
st = parse_switch_status(
{"id": 0, "output": False, "apower": 0.0, "aenergy": {"total": 0.0}}
)
self.assertEqual(st.apower_w, 0.0)
self.assertEqual(st.aenergy_total_wh, 0.0)
class ShellyBaseUrlTests(unittest.TestCase):
def test_defaults(self) -> None:
self.assertEqual(shelly_base_url(None, "192.168.1.50", None), "http://192.168.1.50:80")
def test_https_default_port(self) -> None:
self.assertEqual(shelly_base_url("https", "shelly.local", None), "https://shelly.local:443")
def test_unknown_protocol_falls_back_to_http(self) -> None:
self.assertEqual(shelly_base_url("modbus_tcp", "1.2.3.4", 8080), "http://1.2.3.4:8080")
class ShellyRpcTests(unittest.TestCase):
"""RPC přes httpx.MockTransport — bez sítě."""
def _client(self, handler) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=httpx.MockTransport(handler))
def test_get_switch_status(self) -> None:
seen: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["id"] = request.url.params.get("id")
return httpx.Response(
200,
json={"id": 0, "output": True, "apower": 740.0, "aenergy": {"total": 999.5}},
)
async def run() -> ShellySwitchStatus:
async with self._client(handler) as client:
return await get_switch_status("http://192.168.1.50:80", 0, client=client)
st = asyncio.run(run())
self.assertEqual(seen["path"], "/rpc/Switch.GetStatus")
self.assertEqual(seen["id"], "0")
self.assertTrue(st.output)
self.assertEqual(st.apower_w, 740.0)
self.assertEqual(st.aenergy_total_wh, 999.5)
def test_set_switch_sends_json_bool_and_returns_was_on(self) -> None:
seen: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["id"] = request.url.params.get("id")
seen["on"] = request.url.params.get("on")
return httpx.Response(200, json={"was_on": False})
async def run() -> bool | None:
async with self._client(handler) as client:
return await set_switch("http://192.168.1.50:80/", True, 0, client=client)
was_on = asyncio.run(run())
self.assertEqual(seen["path"], "/rpc/Switch.Set")
self.assertEqual(seen["id"], "0")
# Gen2 RPC parsuje query jako JSON — bool musí být doslova 'true'/'false'.
self.assertEqual(seen["on"], "true")
self.assertIs(was_on, False)
def test_set_switch_off(self) -> None:
seen: dict[str, str] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["on"] = request.url.params.get("on")
return httpx.Response(200, json={"was_on": True})
async def run() -> bool | None:
async with self._client(handler) as client:
return await set_switch("http://10.0.0.7", False, client=client)
self.assertIs(asyncio.run(run()), True)
self.assertEqual(seen["on"], "false")
def test_http_error_raises(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="boom")
async def run() -> None:
async with self._client(handler) as client:
await get_switch_status("http://10.0.0.7", client=client)
with self.assertRaises(httpx.HTTPStatusError):
asyncio.run(run())
def test_non_gen2_body_raises_value_error(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
# Gen1 odpověď — klient ji odmítne (žádný Gen1 fallback).
return httpx.Response(200, content=json.dumps({"ison": True}))
async def run() -> None:
async with self._client(handler) as client:
await get_switch_status("http://10.0.0.7", client=client)
with self.assertRaises(ValueError):
asyncio.run(run())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,472 @@
"""solver_v2 (čisté jádro): tvrdá pravidla, režimy, EV deadline, arbitráž (bez DB)."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.planning.solver_v2 import solve_dispatch_v2
from services.planning.types import PlanningSlot
def _slot(
base: datetime,
i: int,
*,
buy: float,
sell: float,
pv_a: int = 0,
pv_b: int = 0,
load: int = 1000,
ev1: bool = False,
) -> PlanningSlot:
return PlanningSlot(
interval_start=base + timedelta(minutes=15 * i),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=pv_b,
load_baseline_w=load,
ev1_connected=ev1,
ev2_connected=False,
)
def _battery(uc_wh: float = 20_000.0) -> SimpleNamespace:
return SimpleNamespace(
usable_capacity_wh=uc_wh,
min_soc_wh=0.12 * uc_wh,
arb_floor_wh=0.20 * uc_wh,
reserve_soc_wh=0.20 * uc_wh,
soc_max_wh=0.95 * uc_wh,
charge_efficiency=0.95,
discharge_efficiency=0.95,
degradation_cost_czk_kwh=0.5,
max_charge_power_w=8000,
max_discharge_power_w=8000,
planner_terminal_soc_value_factor=0.8,
)
def _grid(block_neg: bool = False, gen_cutoff: bool = False) -> SimpleNamespace:
return SimpleNamespace(
max_import_power_w=17_000,
max_export_power_w=13_500,
block_export_on_negative_sell=block_neg,
deye_gen_microinverter_cutoff_enabled=gen_cutoff,
)
_HP = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
_VEHICLES = [
SimpleNamespace(max_charge_power_w=11_000, battery_capacity_kwh=60.0, default_target_soc_pct=80.0),
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
_BASE = datetime(2026, 6, 10, 0, 0, tzinfo=timezone.utc)
def _solve(
slots,
*,
battery=None,
grid=None,
ev_sessions=(None, None),
soc0=None,
mode="AUTO",
vehicles=None,
):
bat = battery or _battery()
return solve_dispatch_v2(
slots,
bat,
_HP,
grid or _grid(),
list(ev_sessions),
vehicles if vehicles is not None else _VEHICLES,
soc0 if soc0 is not None else 0.5 * bat.usable_capacity_wh,
50.0,
operating_mode=mode,
)
class HardRulesTests(unittest.TestCase):
def test_negative_buy_blocks_export(self) -> None:
slots = [_slot(_BASE, i, buy=-2.0, sell=1.5, pv_a=6000, load=500) for i in range(8)]
results, _, _ = _solve(slots)
for r in results:
self.assertGreaterEqual(r.grid_setpoint_w, 0, "buy<0 → žádný export (pumpa)")
def test_block_export_on_negative_sell(self) -> None:
slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=8000, load=500) for i in range(8)]
results, _, _ = _solve(slots, grid=_grid(block_neg=True))
for r in results:
self.assertGreaterEqual(r.grid_setpoint_w, 0, "KV1: sell<0 → ge=0")
def test_negative_sell_prefers_charge_or_curtail_over_paid_export(self) -> None:
slots = [_slot(_BASE, i, buy=2.0, sell=-1.0, pv_a=8000, load=500) for i in range(8)]
results, _, _ = _solve(slots)
paid_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0)
self.assertEqual(paid_export, 0, "spot: za export při sell<0 se platí → ekonomika ho vyloučí")
def test_battery_export_requires_arb_floor(self) -> None:
bat = _battery()
slots = [_slot(_BASE, i, buy=1.0, sell=8.0, load=500) for i in range(8)]
results, _, _ = _solve(slots, battery=bat, soc0=0.5 * bat.usable_capacity_wh)
for r in results:
if r.grid_setpoint_w < 0 and r.battery_setpoint_w < 0:
self.assertGreaterEqual(
r.battery_soc_target / 100.0 * bat.usable_capacity_wh,
bat.arb_floor_wh - 1.0,
"export z baterie nesmí podlézt arb floor",
)
def test_curtailment_only_pv_a(self) -> None:
# extrémně záporný sell bez block_export: pole B nelze omezit, A ano
slots = [_slot(_BASE, i, buy=2.0, sell=-3.0, pv_a=5000, pv_b=4000, load=300) for i in range(8)]
bat = _battery(uc_wh=2000.0) # malá baterie, ať se přebytek nevejde
results, _, _ = _solve(slots, battery=bat, soc0=0.9 * 2000.0)
self.assertTrue(any(r.pv_a_curtailed_w > 0 for r in results), "A se curtailuje")
for r in results:
self.assertLessEqual(r.pv_a_curtailed_w, 5000, "curtail max = výroba A")
class ArbitrageTests(unittest.TestCase):
def test_cheap_night_charge_expensive_evening_discharge(self) -> None:
slots = [_slot(_BASE, i, buy=1.0, sell=0.5, load=1000) for i in range(16)]
slots += [_slot(_BASE, 16 + i, buy=8.0, sell=7.0, load=1000) for i in range(16)]
results, _, _ = _solve(slots)
charged = sum(r.battery_setpoint_w for r in results[:16] if r.battery_setpoint_w > 0)
discharged = sum(-r.battery_setpoint_w for r in results[16:] if r.battery_setpoint_w < 0)
self.assertGreater(charged, 0, "levná noc → nabíjet")
self.assertGreater(discharged, 0, "drahý večer → vybíjet")
class OperatingModeTests(unittest.TestCase):
def _slots(self):
return [_slot(_BASE, i, buy=1.0, sell=6.0, pv_a=3000, load=1000) for i in range(8)]
def test_preserve_locks_battery(self) -> None:
results, _, _ = _solve(self._slots(), mode="PRESERVE")
for r in results:
self.assertEqual(r.battery_setpoint_w, 0)
def test_charge_cheap_no_export_no_discharge(self) -> None:
results, _, _ = _solve(self._slots(), mode="CHARGE_CHEAP")
for r in results:
self.assertGreaterEqual(r.grid_setpoint_w, 0)
self.assertGreaterEqual(r.battery_setpoint_w, 0)
def test_self_sustain_import_capped_to_load(self) -> None:
results, _, _ = _solve(self._slots(), mode="SELF_SUSTAIN")
for r in results:
self.assertLessEqual(r.grid_setpoint_w, 1000, "import ≤ baseline load")
class NightReserveTests(unittest.TestCase):
def test_night_discharge_respects_buffer(self) -> None:
# noc: vysoký sell, žádné PV; buffer 2000 Wh nad min → plán nesmí
# kalkulovat s vybitím pod min+buffer (sell < buy ⇒ slack se nevyplatí)
bat = _battery()
slots = []
for i in range(16):
s = _slot(_BASE, i, buy=6.0, sell=4.5, load=800)
s.night_baseload_buffer_wh = 2000.0
slots.append(s)
results, _, _ = _solve(slots, battery=bat, soc0=0.6 * bat.usable_capacity_wh)
floor_pct = (bat.min_soc_wh + 2000.0) / bat.usable_capacity_wh * 100.0
for r in results:
self.assertGreaterEqual(r.battery_soc_target, floor_pct - 0.6)
def test_extreme_sell_spike_may_sell_reserve(self) -> None:
# sell výrazně nad buy → racionální polštář prodat (placený slack)
bat = _battery()
slots = []
for i in range(16):
s = _slot(_BASE, i, buy=2.0, sell=12.0, load=300)
s.night_baseload_buffer_wh = 2000.0
slots.append(s)
results, _, _ = _solve(slots, battery=bat, soc0=0.6 * bat.usable_capacity_wh)
min_soc_pct = min(r.battery_soc_target for r in results)
floor_pct = (bat.min_soc_wh + 2000.0) / bat.usable_capacity_wh * 100.0
self.assertLess(min_soc_pct, floor_pct - 1.0, "spike má polštář vyprodat")
def test_start_below_buffer_is_feasible(self) -> None:
bat = _battery()
slots = []
for i in range(8):
s = _slot(_BASE, i, buy=6.0, sell=1.0, load=1500)
s.night_baseload_buffer_wh = 3000.0
slots.append(s)
results, _, _ = _solve(slots, battery=bat, soc0=bat.min_soc_wh + 500.0)
self.assertEqual(len(results), 8)
class DaytimeSafetyRampTests(unittest.TestCase):
def test_morning_tops_up_reserve_before_selling(self) -> None:
# KV1 scénář: ráno baterie u dna, fixní buy 6.35 >> sell 2.5, PV jede;
# s rampou (target 30 % usable) musí nejdřív dotáhnout rezervu, ne prodávat
bat = _battery()
bat.planner_safety_soc_risk_factor = 0.05
target_wh = 0.30 * bat.usable_capacity_wh
slots = []
for i in range(16):
s = _slot(_BASE, i, buy=6.35, sell=2.5, pv_a=6000, load=800)
s.safety_soc_target_wh = target_wh
slots.append(s)
results, _, _ = _solve(slots, battery=bat, soc0=0.11 * bat.usable_capacity_wh)
soc_pct = [r.battery_soc_target for r in results]
first_reach = next((i for i, v in enumerate(soc_pct) if v >= 29.5), None)
self.assertIsNotNone(first_reach, "rampa má dotáhnout na rezervu")
exported_before = sum(
-r.grid_setpoint_w for r in results[:first_reach] if r.grid_setpoint_w < 0
)
self.assertLess(
exported_before, 500 * max(1, first_reach),
"před dosažením rezervy se nemá významně prodávat",
)
def test_sell_spike_beats_ramp(self) -> None:
# extrémní sell nad buy → deficit je racionální podstoupit
bat = _battery()
bat.planner_safety_soc_risk_factor = 0.05
slots = []
for i in range(16):
s = _slot(_BASE, i, buy=2.0, sell=14.0, pv_a=2000, load=300)
s.safety_soc_target_wh = 0.5 * bat.usable_capacity_wh
slots.append(s)
results, _, _ = _solve(slots, battery=bat, soc0=0.45 * bat.usable_capacity_wh)
total_export = sum(-r.grid_setpoint_w for r in results if r.grid_setpoint_w < 0)
self.assertGreater(total_export, 5000, "spike má vyprodat i pod target")
class PvRiskFrontloadTests(unittest.TestCase):
def test_neg_window_charges_asap(self) -> None:
# sell<0 okno, PV >> load, prázdnější baterie: s frontload prémií musí
# nabíjení běžet plným tempem od začátku (ne odložené na konec okna)
bat = _battery()
bat.planner_pv_risk_frontload_czk_kwh = 0.05
slots = [_slot(_BASE, i, buy=2.0, sell=-0.5, pv_a=12000, load=500) for i in range(12)]
results, _, _ = _solve(slots, battery=bat, soc0=0.2 * bat.usable_capacity_wh)
# max tempo: 8 kW × 0.25 h × 0.95 eff = 1.9 kWh/slot = 9.5 p.b. na 20 kWh
soc_mid = results[3].battery_soc_target
self.assertGreaterEqual(
soc_mid, 20.0 + 4 * 9.0,
"frontload: prvni 4 sloty maji nabijet plnym vykonem",
)
class EvOpportunisticTests(unittest.TestCase):
def _session(self, needed=4000.0, headroom=20000.0, opp=1.0):
return SimpleNamespace(
target_deadline=_BASE + timedelta(hours=2),
energy_needed_wh=needed,
headroom_wh=headroom,
opportunistic_value_czk_kwh=opp,
)
def test_negative_prices_fill_beyond_target(self) -> None:
# buy<0 celé okno → nad target se vyplatí brát (hodnota 1 Kč/kWh + platí ti síť)
slots = [_slot(_BASE, i, buy=-1.0, sell=-0.5, ev1=True, load=300) for i in range(16)]
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertGreater(delivered, 4000.0 + 2000.0, "měkký cíl má nasávat")
self.assertLessEqual(delivered, 4000.0 + 20000.0 + 1.0, "strop headroom")
self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0)
def test_normal_prices_no_opportunistic(self) -> None:
# běžné ceny (buy 3) > hodnota 1 Kč/kWh → jen tvrdý cíl
slots = [_slot(_BASE, i, buy=3.0, sell=2.0, ev1=True, load=300) for i in range(16)]
results, _, snap = _solve(slots, ev_sessions=(self._session(), None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertLess(delivered, 4000.0 + 200.0)
self.assertLess(snap["objective_terms"]["ev_opp_wh"][0], 100.0)
def test_cheap_sell_prefers_car_over_grid(self) -> None:
# sell 0.3 < opp 1.0, plná domácí baterka, velký PV přebytek
# → přebytek do auta, ne za babku do sítě
bat = _battery()
slots = [_slot(_BASE, i, buy=3.0, sell=0.3, pv_a=9000, load=500, ev1=True) for i in range(16)]
results, _, snap = _solve(
slots, battery=bat, soc0=bat.soc_max_wh, # baterka plná
ev_sessions=(self._session(needed=2000.0, headroom=25000.0), None),
)
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
exported = sum(-r.grid_setpoint_w * 0.25 for r in results if r.grid_setpoint_w < 0)
self.assertGreater(delivered, 15000.0, "přebytek má téct do auta")
self.assertLess(exported, delivered, "prodej za 0.3 nemá vyhrát nad autem")
def test_total_energy_capped_even_at_negative_buy(self) -> None:
# fix latentního bugu: bez headroom (opp=0) nesmí buy<0 pumpovat nad needed
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(16)]
sess = self._session(needed=3000.0, headroom=0.0, opp=0.0)
results, _, _ = _solve(slots, ev_sessions=(sess, None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertLessEqual(delivered, 3000.0 + 1.0)
class EvAccountingTests(unittest.TestCase):
"""EV účtování 2026-06-12: deadline boundary, stop-session, fyzikální split,
min. výkon wallboxu, opp po deadline, battery_arbitrage_czk reporting."""
def test_deadline_boundary_slot_excluded(self) -> None:
# slot začínající přesně v deadline (slot 4) už do deadline nepatří;
# levné sloty 4..7 nesmí krýt tvrdý cíl (dřív off-by-one t_dl+1)
slots = [
_slot(_BASE, i, buy=5.0 if i < 4 else 0.5, sell=0.2, ev1=True)
for i in range(8)
]
session = SimpleNamespace(
target_deadline=_BASE + timedelta(hours=1), # = start slotu 4
energy_needed_wh=4000.0,
headroom_wh=0.0,
opportunistic_value_czk_kwh=0.0,
)
results, _, snap = _solve(slots, ev_sessions=(session, None))
before = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:4])
after = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:])
self.assertGreaterEqual(before, 4000.0 - 1.0, "tvrdý cíl jen sloty PŘED deadline")
self.assertLessEqual(after, 1.0, "slot v deadline a dál nekryje tvrdý cíl")
self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0])
def test_stop_session_zero_everywhere(self) -> None:
# needed 0 + opp 0 (stop-session) → EV nula i při záporných cenách
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)]
session = SimpleNamespace(
target_deadline=_BASE + timedelta(hours=2),
energy_needed_wh=0.0,
headroom_wh=0.0,
opportunistic_value_czk_kwh=0.0,
)
results, _, _ = _solve(slots, ev_sessions=(session, None))
for r in results:
self.assertEqual(r.ev1_setpoint_w or 0, 0)
def test_no_session_zero_even_at_negative_buy(self) -> None:
# připojené auto BEZ session nemá mandát nabíjet (golden fixtures)
slots = [_slot(_BASE, i, buy=-2.0, sell=-1.0, ev1=True, load=300) for i in range(8)]
results, _, _ = _solve(slots, ev_sessions=(None, None))
for r in results:
self.assertEqual(r.ev1_setpoint_w or 0, 0)
def test_ev_direct_within_grid_plus_pv(self) -> None:
# fyzikální split: direct (= setpoint via_bat) nesmí překročit gi + PV
slots = [
_slot(_BASE, i, buy=2.0, sell=1.0, pv_a=(3000 if i < 4 else 0), ev1=True)
for i in range(12)
]
bat = _battery()
session = SimpleNamespace(
target_deadline=_BASE + timedelta(hours=3),
energy_needed_wh=10000.0,
headroom_wh=0.0,
opportunistic_value_czk_kwh=0.0,
)
results, _, _ = _solve(
slots, battery=bat, soc0=0.9 * bat.usable_capacity_wh,
ev_sessions=(session, None),
)
for i, r in enumerate(results):
direct = (r.ev1_setpoint_w or 0) - r.ev1_via_bat_w
gi_w = max(0, r.grid_setpoint_w)
pv_w = slots[i].pv_a_forecast_w + slots[i].pv_b_forecast_w
self.assertLessEqual(direct, gi_w + pv_w + 2, f"slot {i}: direct > gi+pv")
def test_min_power_setpoints_zero_or_above_min(self) -> None:
# wallbox min 1380 W (6 A): setpoint ∈ {0} [1380, max] — žádné 400900 W
vehicles = [
SimpleNamespace(
max_charge_power_w=11_000, min_power_w=1380,
battery_capacity_kwh=60.0, default_target_soc_pct=80.0,
),
_VEHICLES[1],
]
# ceny nutí rozprostřít malé množství energie → bez binárky by vyšlo ~86 W/slot
slots = [_slot(_BASE, i, buy=2.0 + 0.01 * i, sell=1.0, ev1=True) for i in range(8)]
session = SimpleNamespace(
target_deadline=_BASE + timedelta(hours=2),
energy_needed_wh=690.0, # 2 sloty × 1380 W × 0.25 h
headroom_wh=0.0,
opportunistic_value_czk_kwh=0.0,
)
results, _, _ = _solve(slots, ev_sessions=(session, None), vehicles=vehicles)
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertGreaterEqual(delivered, 690.0 - 1.0)
for i, r in enumerate(results):
sp = r.ev1_setpoint_w or 0
self.assertTrue(
sp == 0 or sp >= 1379,
f"slot {i}: setpoint {sp} W je pod minimem wallboxu",
)
def test_opportunistic_after_deadline_allowed(self) -> None:
# ROZHODNUTO 2026-06-12: opp vrstva NENÍ omezená deadline — záporné ceny
# po deadline smí téct do auta (odjezd řeší rolling replan)
slots = [
_slot(_BASE, i, buy=(3.0 if i < 4 else -1.5), sell=(1.0 if i < 4 else -0.5),
ev1=True, load=300)
for i in range(16)
]
session = SimpleNamespace(
target_deadline=_BASE + timedelta(hours=1), # slot 4
energy_needed_wh=2000.0,
headroom_wh=20000.0,
opportunistic_value_czk_kwh=1.0,
)
results, _, snap = _solve(slots, ev_sessions=(session, None))
after_deadline = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[4:])
total = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertGreater(after_deadline, 0.0, "opp po deadline musí zůstat povolené")
self.assertLessEqual(total, 2000.0 + 20000.0 + 1.0, "strop needed + headroom")
self.assertGreater(snap["objective_terms"]["ev_opp_wh"][0], 0.0)
def test_battery_arbitrage_reported_for_via_bat(self) -> None:
# EV kryté z baterie (noc, drahý buy, plná baterie) → via_bat > 0 a
# battery_arbitrage_czk nese oportunitní cenu (ne konstantní 0)
bat = _battery()
slots = [_slot(_BASE, i, buy=8.0, sell=1.0, ev1=True, load=300) for i in range(8)]
session = SimpleNamespace(
target_deadline=_BASE + timedelta(hours=2),
energy_needed_wh=6000.0,
headroom_wh=0.0,
opportunistic_value_czk_kwh=0.0,
)
results, _, _ = _solve(
slots, battery=bat, soc0=bat.soc_max_wh, ev_sessions=(session, None)
)
via = sum(r.ev1_via_bat_w for r in results)
self.assertGreater(via, 0, "drahý buy + plná baterie → EV z baterie")
arb = sum(r.battery_arbitrage_czk for r in results)
self.assertGreater(arb, 0.0, "via_bat sloty musí reportovat oportunitní Kč")
for r in results:
if r.ev1_via_bat_w == 0:
self.assertEqual(r.battery_arbitrage_czk, 0.0)
class EvDeadlineTests(unittest.TestCase):
def test_ev_energy_delivered_before_deadline(self) -> None:
slots = [_slot(_BASE, i, buy=2.0 if i < 8 else 6.0, sell=1.0, ev1=True) for i in range(16)]
session = SimpleNamespace(
target_deadline=_BASE + timedelta(hours=4), # slot 16 → vše do konce
energy_needed_wh=8000.0,
)
results, _, snap = _solve(slots, ev_sessions=(session, None))
delivered = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results)
self.assertGreaterEqual(delivered, 8000.0 - 1.0)
self.assertEqual(snap["objective_terms"]["ev_unmet_wh"], [0.0])
# levné sloty (07) mají dodat většinu energie
cheap = sum((r.ev1_setpoint_w or 0) * 0.25 for r in results[:8])
self.assertGreater(cheap, 4000.0, "EV nabíjí přednostně v levných slotech")
def test_ev_unreachable_deadline_uses_paid_slack(self) -> None:
slots = [_slot(_BASE, i, buy=2.0, sell=1.0, ev1=(i == 0)) for i in range(8)]
session = SimpleNamespace(
target_deadline=_BASE + timedelta(minutes=15),
energy_needed_wh=50_000.0, # nesplnitelné za 1 slot
)
results, _, snap = _solve(slots, ev_sessions=(session, None))
self.assertGreater(snap["objective_terms"]["ev_unmet_wh"][0], 0.0, "slack místo infeasible")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,263 @@
"""Idle-skip zápisů telemetrie: _idle_skip + detekce příjezdu/odjezdu EV přes skip."""
from __future__ import annotations
import asyncio
import unittest
from unittest.mock import AsyncMock, patch
import services.telemetry_collector as tc
from services.telemetry_collector import (
IDLE_SKIP_MAX_GAP_S,
TELTO_REG_BLOCK_COUNT,
_idle_skip,
_sig_round,
)
class IdleSkipTests(unittest.TestCase):
KEY = ("telemetry_test", 1)
def setUp(self) -> None:
tc._IDLE_SKIP_STATE.clear()
def test_first_sample_after_start_is_stored(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
def test_unchanged_idle_sample_is_skipped(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1060.0))
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1120.0))
def test_signature_change_is_stored(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
self.assertFalse(_idle_skip(self.KEY, ("a", 100), False, 1060.0))
def test_active_device_is_always_stored(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), True, 1000.0))
self.assertFalse(_idle_skip(self.KEY, ("a", 0), True, 1060.0))
def test_heartbeat_after_max_gap(self) -> None:
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
# přesně na hranici se ještě přeskakuje (> max_gap_s, ne >=)
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S))
self.assertFalse(
_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S + 1.0)
)
# heartbeat resetuje last_stored_at → další idle vzorek se zase přeskočí
self.assertTrue(
_idle_skip(self.KEY, ("a", 0), False, 1000.0 + IDLE_SKIP_MAX_GAP_S + 61.0)
)
def test_keys_are_independent(self) -> None:
other = ("telemetry_test", 2)
self.assertFalse(_idle_skip(self.KEY, ("a", 0), False, 1000.0))
self.assertFalse(_idle_skip(other, ("a", 0), False, 1060.0))
self.assertTrue(_idle_skip(self.KEY, ("a", 0), False, 1060.0))
def test_sig_round(self) -> None:
self.assertIsNone(_sig_round(None, 0.2))
self.assertEqual(_sig_round(47.31, 0.2), 47.4)
self.assertEqual(_sig_round(47.29, 0.2), 47.2)
self.assertEqual(_sig_round(-3.1, 0.2), -3.2)
def _frame_regs(status_raw: int, power_w: int = 0) -> list[int]:
regs = [0] * TELTO_REG_BLOCK_COUNT
regs[0] = 230
regs[6] = status_raw # 7 = available, 0 = charging
regs[38] = power_w
return regs
class _FakeBatch:
def __init__(self, regs: list[int]) -> None:
self._regs = regs
async def __aenter__(self) -> "_FakeBatch":
return self
async def __aexit__(self, *args: object) -> bool:
return False
async def read_holding_registers(self, start: int, count: int) -> list[int]:
return self._regs
class _FakeModbusClient:
def __init__(self) -> None:
self.regs: list[int] = _frame_regs(7)
def batch(self, unit_id: int) -> _FakeBatch:
return _FakeBatch(self.regs)
class _FakeDB:
"""Min. asyncpg.Connection náhrada pro poll_ev_chargers."""
def __init__(self, latest_status: str | None) -> None:
self.latest_status = latest_status
self.inserts: list[tuple] = []
self.transitions: list[tuple[str, str]] = []
async def fetch(self, query: str, *args: object) -> list[dict]:
return [{"id": 7, "code": "ev-charger-1", "host": "h", "port": 502, "unit_id": 1}]
async def fetchval(self, query: str, *args: object):
if "vw_latest_ev_charger" in query:
return self.latest_status
if "fn_ev_session_transition" in query:
self.transitions.append((str(args[2]), str(args[3])))
return None
raise AssertionError(f"unexpected fetchval: {query}")
async def execute(self, query: str, *args: object) -> None:
assert "fn_telemetry_ev_charger_sample" in query
self.inserts.append(args)
class EvArrivalSurvivesIdleSkipTests(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
tc._IDLE_SKIP_STATE.clear()
tc._EV_LAST_STATUS.clear()
async def _poll(self, db: _FakeDB, client: _FakeModbusClient) -> None:
with (
patch.object(tc, "get_modbus_client", AsyncMock(return_value=client)),
patch.object(tc, "_on_ev_arrival", AsyncMock()) as arrival,
patch.object(tc, "_on_ev_departure", AsyncMock()) as departure,
):
await tc.poll_ev_chargers(1, db) # type: ignore[arg-type]
await asyncio.sleep(0) # nechat doběhnout create_task
self.arrival_called = arrival.await_count > 0
self.departure_called = departure.await_count > 0
async def test_arrival_detected_after_skipped_idle_samples(self) -> None:
db = _FakeDB(latest_status=None)
client = _FakeModbusClient()
# 1. tick po startu: available → uloží se (prázdný stav), žádný příjezd
await self._poll(db, client)
self.assertEqual(len(db.inserts), 1)
self.assertFalse(self.arrival_called)
self.assertEqual(db.transitions, [])
# 2.3. tick: idle beze změny → řádky se přeskočí
await self._poll(db, client)
await self._poll(db, client)
self.assertEqual(len(db.inserts), 1)
# 4. tick: EV se připojí (charging) → insert + transition + arrival hook
client.regs = _frame_regs(0, power_w=11000)
await self._poll(db, client)
self.assertEqual(len(db.inserts), 2)
self.assertEqual(db.transitions, [("available", "charging")])
self.assertTrue(self.arrival_called)
# 5. tick: nabíjí dál (aktivní) → ukládá se každou minutu, bez transition
await self._poll(db, client)
self.assertEqual(len(db.inserts), 3)
self.assertEqual(len(db.transitions), 1)
# 6. tick: odpojení → departure
client.regs = _frame_regs(7)
await self._poll(db, client)
self.assertEqual(db.transitions[-1], ("charging", "available"))
self.assertTrue(self.departure_called)
async def test_no_false_arrival_after_restart(self) -> None:
# restart procesu: in-memory stav prázdný, DB má poslední řádek 'charging',
# nabíječka stále nabíjí → žádný falešný příjezd
db = _FakeDB(latest_status="charging")
client = _FakeModbusClient()
client.regs = _frame_regs(0, power_w=11000)
await self._poll(db, client)
self.assertFalse(self.arrival_called)
self.assertEqual(db.transitions, [])
async def test_transition_across_restart_detected(self) -> None:
# během výpadku backendu EV přijelo: DB 'available', teď 'charging'
db = _FakeDB(latest_status="available")
client = _FakeModbusClient()
client.regs = _frame_regs(0, power_w=11000)
await self._poll(db, client)
self.assertEqual(db.transitions, [("available", "charging")])
self.assertTrue(self.arrival_called)
class _FakeConn:
async def execute(self, *args: object, **kwargs: object) -> None:
return None
async def fetchval(self, *args: object, **kwargs: object) -> object:
return None
class _FakeAcquireCtx:
def __init__(self, conn: _FakeConn) -> None:
self._conn = conn
async def __aenter__(self) -> _FakeConn:
return self._conn
async def __aexit__(self, *exc: object) -> bool:
return False
class _FakePool:
def __init__(self) -> None:
self.conn = _FakeConn()
def acquire(self) -> _FakeAcquireCtx:
return _FakeAcquireCtx(self.conn)
class EvDepartureTriggersReplanTests(unittest.IsolatedAsyncioTestCase):
"""Odjezd EV musí okamžitě přeplánovat (ne čekat na */15) — symetrie k příjezdu."""
async def test_departure_triggers_replan_and_export(self) -> None:
import app.db_json as dbj
import services.control_exporter as ce
import services.planning_engine as pe
replan = AsyncMock()
export = AsyncMock()
# OBS část: non-tesla ctx → krátí se před voláním Tesla API.
fake_fetch = AsyncMock(return_value={"api_type": "loxone"})
with (
patch.object(tc, "_BG_POOL", _FakePool()),
patch.object(pe, "run_rolling_replan", replan),
patch.object(ce, "export_setpoints", export),
patch.object(dbj, "fetch_json", fake_fetch),
):
await tc._on_ev_departure(2, "vt-ev-charger-1")
replan.assert_awaited_once()
_, kwargs = replan.await_args
self.assertEqual(kwargs.get("triggered_by"), "ev_departure:vt-ev-charger-1")
export.assert_awaited_once()
async def test_departure_replan_failure_does_not_block_obs(self) -> None:
# Replan spadne → OBS část (jiný conn/try) musí proběhnout dál bez výjimky.
import app.db_json as dbj
import services.control_exporter as ce
import services.planning_engine as pe
replan = AsyncMock(side_effect=RuntimeError("solver down"))
export = AsyncMock()
fake_fetch = AsyncMock(return_value={"api_type": "loxone"})
with (
patch.object(tc, "_BG_POOL", _FakePool()),
patch.object(pe, "run_rolling_replan", replan),
patch.object(ce, "export_setpoints", export),
patch.object(dbj, "fetch_json", fake_fetch),
):
await tc._on_ev_departure(2, "vt-ev-charger-1") # nesmí vyhodit
replan.assert_awaited_once()
export.assert_not_awaited() # export se po pádu replanu nevolá
fake_fetch.assert_awaited() # OBS část přesto běžela
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,58 @@
"""Parser rámce TeltoCharge (registry 040) a mapování stavů na EV session logiku."""
from __future__ import annotations
import unittest
from services.telemetry_collector import (
TELTO_REG_BLOCK_COUNT,
TELTO_STATUS_MAP,
parse_teltocharge_frame,
)
def _frame(**over: int) -> list[int]:
regs = [0] * TELTO_REG_BLOCK_COUNT
regs[0], regs[1], regs[2] = 230, 231, 229 # napětí
regs[3], regs[4], regs[5] = 160, 158, 0 # proud ×10 A (16.0 A max)
regs[6] = 7 # A bez EV
regs[38] = 0 # výkon W
regs[39] = 0 # session kWh ×100
for k, v in over.items():
regs[int(k.lstrip("r"))] = v
return regs
class TeltoChargeParseTests(unittest.TestCase):
def test_charging_frame(self) -> None:
f = parse_teltocharge_frame(_frame(r6=0, r38=10870, r39=523))
self.assertEqual(f["status"], "charging")
self.assertEqual(f["power_w"], 10870)
self.assertAlmostEqual(f["session_energy_kwh"], 5.23)
self.assertAlmostEqual(f["current_a"], 16.0)
def test_no_ev_is_available(self) -> None:
self.assertEqual(parse_teltocharge_frame(_frame(r6=7))["status"], "available")
def test_all_connected_states_are_not_available(self) -> None:
# detekce příjezdu (fn_ev_session_transition) stojí na ≠ 'available'
for raw, mapped in TELTO_STATUS_MAP.items():
if raw == 7:
continue
self.assertNotEqual(mapped, "available", f"EVSE status {raw}")
def test_unknown_raw_status(self) -> None:
self.assertEqual(parse_teltocharge_frame(_frame(r6=42))["status"], "unknown")
def test_error_bits_passthrough(self) -> None:
f = parse_teltocharge_frame(_frame(r6=8, r35=0b10000))
self.assertEqual(f["status"], "faulted")
self.assertEqual(f["error_bits"], 16)
def test_short_frame_raises(self) -> None:
with self.assertRaises(ValueError):
parse_teltocharge_frame([0] * 10)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,51 @@
"""Tesla Fleet API čisté parsery (bez sítě/DB)."""
from __future__ import annotations
import unittest
from services.tesla_client import parse_charge_state
class ParseChargeStateTests(unittest.TestCase):
def test_full_response(self) -> None:
data = {
"response": {
"vin": "5YJYGDEE0MF000000",
"charge_state": {
"battery_level": 47,
"charge_limit_soc": 80,
"charging_state": "Stopped",
},
}
}
out = parse_charge_state(data)
self.assertEqual(out["battery_level"], 47)
self.assertEqual(out["charge_limit_soc"], 80)
self.assertEqual(out["vin"], "5YJYGDEE0MF000000")
def test_missing_level_returns_none(self) -> None:
self.assertIsNone(parse_charge_state({"response": {"charge_state": {}}}))
self.assertIsNone(parse_charge_state({}))
def test_odometer_miles_to_km(self) -> None:
data = {
"response": {
"charge_state": {"battery_level": 60},
"vehicle_state": {"odometer": 12345.6}, # míle!
}
}
out = parse_charge_state(data)
self.assertAlmostEqual(out["odometer_km"], 19868.3, places=1)
def test_missing_odometer_is_none(self) -> None:
data = {"response": {"charge_state": {"battery_level": 60}}}
self.assertIsNone(parse_charge_state(data)["odometer_km"])
def test_zero_limit_normalized_to_none(self) -> None:
data = {"response": {"charge_state": {"battery_level": 10, "charge_limit_soc": 0}}}
self.assertIsNone(parse_charge_state(data)["charge_limit_soc"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,25 @@
-- Parametry pro denní „safety charge“ (měkké LP penalizace) a kotvu rolling replanu.
alter table ems.asset_battery
add column if not exists planner_daytime_charge_target_enabled boolean not null default true;
alter table ems.asset_battery
add column if not exists planner_night_baseload_buffer_percent numeric not null default 20;
alter table ems.asset_battery
add column if not exists planner_daytime_charge_price_quantile numeric not null default 0.70;
alter table ems.asset_battery
add column if not exists planner_charge_commitment_penalty_czk_kwh numeric not null default 0.20;
comment on column ems.asset_battery.planner_daytime_charge_target_enabled is
'Zapíná SQL/LP měkké denní cíle SoC (safety) z fn_load_planning_slots_full; ne tvrdé allow_charge masky.';
comment on column ems.asset_battery.planner_night_baseload_buffer_percent is
'Procentní přirážka k odhadu nočního baseload Wh (20 = +20 % k night_baseload_target_wh).';
comment on column ems.asset_battery.planner_daytime_charge_price_quantile is
'Rezervováno pro budoucí výběr „drahých“ oken z cenové distribuce; v1 se v LP nepoužívá.';
comment on column ems.asset_battery.planner_charge_commitment_penalty_czk_kwh is
'Koeficient měkké penalizace (Kč/kWh krátkého nedodržení) proti předchozímu plánu při rolling replanu.';

View File

@@ -0,0 +1,10 @@
-- export_mode / export_limit_w z LP — potřeba pro control exporter (reg 142/143)
alter table ems.planning_interval
add column if not exists export_mode text,
add column if not exists export_limit_w int;
comment on column ems.planning_interval.export_mode is
'Záměr exportu z solveru: NONE / PV_SURPLUS / BATTERY_SELL.';
comment on column ems.planning_interval.export_limit_w is
'Tvrdý limit exportu do sítě (W) v slotu; 0 = bez exportu.';

View File

@@ -0,0 +1,23 @@
-- Cache výsledku fn_pv_forecast_delta_profile per site (obnovuje job fn_fill_forecast_accuracy).
-- Zrychlení GET /plan/current a plánování (canonical PV forecast).
alter table ems.site_pv_forecast_calibration
add column if not exists delta_profile_cache jsonb null,
add column if not exists delta_profile_cached_at timestamptz null;
comment on column ems.site_pv_forecast_calibration.delta_profile_cache is
'Poslední JSON z fn_pv_forecast_delta_profile (120d lookback, now); NULL = ještě nepočítáno.';
comment on column ems.site_pv_forecast_calibration.delta_profile_cached_at is
'Čas posledního refresh cache (fn_refresh_site_pv_delta_profile_cache).';
create index if not exists idx_planning_run_site_comparison_of
on ems.planning_run (
site_id,
((solver_params->>'comparison_of_run_id')::int),
created_at desc
)
where status = 'comparison';
comment on index ems.idx_planning_run_site_comparison_of is
'Rychlé nalezení comparison runu pro GET /plan/compare (comparison_of_run_id v solver_params).';

View File

@@ -0,0 +1,181 @@
-- =============================================================
-- V080__seed_site_hulin_bess.sql
-- Idempotentní seed BESS lokality Hulín, Krátká 780 (bez FVE, nízká vlastní spotřeba).
-- Střídač Deye 2×20 kW (AC max 40 kW), baterie 4×32 kWh (128 kWh usable).
-- BMS z/do baterie max 2×350 A (~36 kW); jistič import ~63 A (~43 kW); export max 42 kW.
-- Viz docs/new-site-setup-template.md (sekce BESS bez FVE).
-- =============================================================
do $$
declare
v_site_code text := 'hulin-bess';
-- Modbus host doplnit před zapnutím endpointu (enabled = true).
v_host_deye text := '0.0.0.0';
v_port_deye int := 502;
v_site_id int;
v_ep_deye int;
v_inv_main int;
begin
insert into ems.site (code, name, timezone, latitude, longitude, active, notes)
values (
v_site_code,
'Hulín, Krátká 780 (BESS)',
'Europe/Prague',
49.312314,
17.474594,
true,
'Adresa: Krátká 780, 768 24 Hulín. BESS ukládání energie bez FVE, bez významné vlastní spotřeby. '
'Střídač Deye 2×20 kW; baterie 4×32 kWh; BMS max ~36 kW z/do baterie; jistič ~43 kW import, export 42 kW. '
'Souřadnice pro případnou budoucí FVE / počasí. Modbus endpoint zatím vypnutý doplnit IP a enabled.'
)
on conflict (code) do update set
name = excluded.name,
timezone = excluded.timezone,
latitude = excluded.latitude,
longitude = excluded.longitude,
active = excluded.active,
notes = excluded.notes
returning id into v_site_id;
select se.id into v_ep_deye
from ems.site_endpoint se
where se.site_id = v_site_id
and se.endpoint_type = 'modbus_tcp'
and se.notes ilike '%Deye%'
order by se.id
limit 1;
if v_ep_deye is null then
insert into ems.site_endpoint (
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
)
values (
v_site_id, 'modbus_tcp', v_host_deye, v_port_deye, 'modbus_tcp', 1, false,
'Deye 2×20 kW Modbus TCP (Waveshare). Host/IP doplnit před enabled = true.'
)
returning id into v_ep_deye;
end if;
insert into ems.site_grid_connection (
site_id,
max_import_power_w,
max_export_power_w,
no_export,
reserved_capacity_w,
block_export_on_negative_sell,
notes
)
values (
v_site_id,
43000,
42000,
false,
0,
true,
'Hlavní jistič ~63 A → import cca 43 kW. Export do DS max 42 kW. '
'BESS bez FVE block_export_on_negative_sell pro zápornou výkupní cenu v LP.'
)
on conflict (site_id) do update set
max_import_power_w = excluded.max_import_power_w,
max_export_power_w = excluded.max_export_power_w,
no_export = excluded.no_export,
reserved_capacity_w = excluded.reserved_capacity_w,
block_export_on_negative_sell = excluded.block_export_on_negative_sell,
notes = excluded.notes;
if not exists (
select 1 from ems.site_market_config smc
where smc.site_id = v_site_id and smc.valid_to is null
) then
insert into ems.site_market_config (
site_id,
purchase_pricing_mode, sale_pricing_mode,
buy_margin_fixed_czk, buy_margin_percent,
sell_margin_fixed_czk, sell_margin_percent,
currency, valid_from, valid_to, notes,
tariff_id, hdo_code_id, system_services_czk_kwh, ote_fee_czk_kwh
)
values (
v_site_id,
'spot', 'spot',
0.050, 0,
-0.020, 0,
'CZK', now(), null,
'Výchozí spot nákup/prodej (marže jako home-01). Upřesnit dle smlouvy provozovatele BESS.',
null, null, 0, 0
);
end if;
insert into ems.site_operating_mode (site_id, mode_code, activated_by, notes)
values (
v_site_id,
'MANUAL',
'migration:V080_seed_site_hulin_bess',
'Start MANUAL (bez zápisů na Deye). Po ověření Modbus a SoC přepnout na AUTO.'
)
on conflict (site_id) do nothing;
select ai.id into v_inv_main
from ems.asset_inverter ai
where ai.site_id = v_site_id and ai.code = 'deye-main'
limit 1;
if v_inv_main is null then
insert into ems.asset_inverter (
site_id, code, manufacturer, model, endpoint_id,
max_charge_power_w, max_discharge_power_w, max_export_power_w,
max_ac_output_w, max_dc_input_w, max_battery_charge_w, max_battery_discharge_w,
gen_port_max_power_w,
deye_register_max_charge_a, deye_register_max_discharge_a,
deye_zero_export_mode,
controllable, active, notes
)
values (
v_site_id,
'deye-main',
'Deye',
'2× SUN-20K (40 kW AC)',
v_ep_deye,
36000, 36000, 42000,
40000, 0, 36000, 36000,
null,
350, 350,
2,
true, true,
'Hybrid 2×20 kW. BMS limit z/do baterie 2×350 A (~36 kW). AC/střídač max 40 kW. '
'Reg 108/109 cap 350 A. deye_zero_export_mode=2 (CT na odběrném místě) ověřit po instalaci.'
)
returning id into v_inv_main;
end if;
if not exists (
select 1 from ems.asset_battery ab
where ab.site_id = v_site_id and ab.code = 'bat-main'
) then
insert into ems.asset_battery (
site_id, inverter_id, code,
usable_capacity_wh, min_soc_percent, reserve_soc_percent, max_soc_percent,
charge_efficiency, discharge_efficiency, degradation_cost_czk_kwh,
max_charge_c_rate, max_discharge_c_rate, bms_max_charge_w, bms_max_discharge_w,
planner_max_soc_percent,
charge_slot_buffer, discharge_slot_buffer
)
values (
v_site_id, v_inv_main, 'bat-main',
128000,
10, 10, 95,
0.95, 0.95,
0.50,
0.5, 0.5,
36000, 36000,
100,
1.3, 1.5
);
end if;
-- Žádné asset_pv_array / EV / TČ čistý BESS arbitrážní uzel.
end;
$$;

View File

@@ -0,0 +1,25 @@
-- Rozsireni ekonomickeho rozpadu planu (audit transparence: cashflow vs arbitraz vs penalizace vs bonus).
-- Drive byl v planning_interval jen expected_cost_czk = gi*buy - ge*sell (bez penalizaci a bez acquisition).
alter table ems.planning_interval
add column if not exists cashflow_czk numeric,
add column if not exists battery_arbitrage_czk numeric,
add column if not exists penalty_czk numeric,
add column if not exists green_bonus_czk numeric;
comment on column ems.planning_interval.cashflow_czk is
'Net penezni tok ze site v slotu: gi*buy_price*h - ge*sell_price*h (Kc). '
'Kladne = platba EMS, zaporne = prijem. Shodne s expected_cost_czk (ponechano jako legacy).';
comment on column ems.planning_interval.battery_arbitrage_czk is
'Marze z exportu baterie do site: ge_bat * (sell_price - acquisition_used) * h (Kc). '
'Kladne = zisk arbitraze (cena prodeje > vazeny nakup zasoby).';
comment on column ems.planning_interval.penalty_czk is
'Soucet penalizaci v slotu (Kc): shortfall (peak_export, pv_charge, neg_sell_dump) + safety_deficit '
'+ curtailment + commitment. Neviditelne v cashflow_czk, ale solver je optimalizuje.';
comment on column ems.planning_interval.green_bonus_czk is
'Planovany zeleny bonus z vyroby poli s active green_bonus_czk_kwh (Kc). '
'pv_*_forecast_solver_w * green_bonus_czk_kwh * h, scitano pres vsechna pole se zelenym bonusem '
'platnym v slotu (ems.asset_pv_array.green_bonus_*).';

View File

@@ -0,0 +1,33 @@
-- Reg 340 (max solar power): strop dle výkonu střídače, ne součtu Wp polí; min dle firmware.
alter table ems.asset_inverter
add column if not exists deye_reg340_max_solar_w int,
add column if not exists deye_reg340_min_solar_w int not null default 0;
comment on column ems.asset_inverter.deye_reg340_max_solar_w is
'Horní strop zápisu Deye reg 340 (max solar power, W). Studené panely mohou překročit součet Wp — použít plný DC limit střídače (např. 32000 home-01, 65000 větší hybridy). NULL = fallback max_dc_input_w, pak součet Wp řiditelných polí.';
comment on column ems.asset_inverter.deye_reg340_min_solar_w is
'Minimální hodnota reg 340 přijatá firmwarem střídače (W). 0 = bez spodního limitu; starší Deye (home-01) často 400.';
-- home-01: SUN-20K, reg 340 max 32 kW, firmware min 400 W
update ems.asset_inverter inv
set
deye_reg340_max_solar_w = 32000,
deye_reg340_min_solar_w = 400
from ems.site s
where s.id = inv.site_id
and s.code = 'home-01'
and inv.code = 'deye-main'
and inv.controllable = true;
-- Ostatní řízené Deye hybridy: 65 kW strop, min 0 (novější firmware)
update ems.asset_inverter inv
set
deye_reg340_max_solar_w = coalesce(inv.deye_reg340_max_solar_w, 65000),
deye_reg340_min_solar_w = 0
from ems.site s
where s.id = inv.site_id
and s.code <> 'home-01'
and inv.code = 'deye-main'
and inv.controllable = true
and inv.active = true;

View File

@@ -0,0 +1,35 @@
-- Fázované SoC a curtail v okně sell < 0 (plánovač v32).
alter table ems.asset_battery
add column if not exists planner_neg_sell_prep_soc_percent numeric(5, 2) not null default 80;
alter table ems.asset_battery
add column if not exists planner_neg_sell_full_soc_tail_slots int not null default 4;
alter table ems.asset_battery
add column if not exists planner_neg_sell_vent_min_sell_czk_kwh numeric;
comment on column ems.asset_battery.planner_neg_sell_prep_soc_percent is
'Cíl SoC (%) v hlavní části denního okna sell<0 (ASAP nabít z FVE). 100 = legacy (tlak na soc_max až na konci). Realizace škrcení A přes plánovaný pv_a_curtailed_w → Deye reg 340.';
comment on column ems.asset_battery.planner_neg_sell_full_soc_tail_slots is
'Počet 15min slotů před koncem denního úseku sell<0 (Europe/Prague), kdy LP rampuje cíl SoC na soc_max. 0 = bez tail fáze (legacy).';
comment on column ems.asset_battery.planner_neg_sell_vent_min_sell_czk_kwh is
'V tail fázi: dobrovolný ventil pole B (ge_pv) jen pokud effective sell >= tato hodnota (Kč/kWh). NULL = vent jen při plné baterii (stávající w_pv_b_vent).';
update ems.asset_battery ab
set
planner_neg_sell_prep_soc_percent = 80,
planner_neg_sell_full_soc_tail_slots = 4,
planner_neg_sell_vent_min_sell_czk_kwh = -1.0
from ems.site s
where ab.site_id = s.id
and s.code = 'home-01';
update ems.asset_battery ab
set planner_neg_sell_prep_soc_percent = 100
from ems.site s
join ems.site_grid_connection sgc on sgc.site_id = s.id
where ab.site_id = s.id
and coalesce(sgc.block_export_on_negative_sell, false) = true;

View File

@@ -0,0 +1,14 @@
-- Journal neúspěšných běhů plánovače (Solver: Infeasible po celém retry řetězci).
alter table ems.planning_run
add column if not exists error_text text;
comment on column ems.planning_run.error_text is
'Chybová zpráva u status=failed (typicky Solver: Infeasible); aktivní plán se nemění.';
comment on column ems.planning_run.status is
'Stav plánu: draft, approved, active, superseded, comparison (shadow běh), failed (solver selhal).';
create index if not exists idx_planning_run_site_failed
on ems.planning_run (site_id, created_at desc)
where status = 'failed';

View File

@@ -0,0 +1,25 @@
-- Tesla Fleet API: VIN na vozidle, aktivace api_type pro Model Y (home-01),
-- singleton tabulka tokenů (refresh token Tesla ROTUJE při každém použití —
-- nelze ho držet jen v .env, runtime hodnota žije zde; .env je jen seed).
alter table ems.asset_vehicle
add column if not exists vin text;
comment on column ems.asset_vehicle.vin is
'VIN pro párování s vozidlem v API výrobce (Tesla Fleet). NULL = doplní se automaticky při prvním úspěšném čtení (jediné vozidlo na účtu), jinak nutno vyplnit ručně.';
update ems.asset_vehicle
set api_type = 'tesla'
where code = 'tesla-my'
and site_id = (select id from ems.site where code = 'home-01');
create table if not exists ems.tesla_token (
id int primary key default 1 check (id = 1),
refresh_token text not null,
access_token text,
access_expires_at timestamptz,
updated_at timestamptz not null default now()
);
comment on table ems.tesla_token is
'Singleton: aktuální Tesla Fleet API tokeny. Seed refresh tokenu z env TESLA_REFRESH_TOKEN při prvním použití; rotace ukládá fn_tesla_token_upsert.';

View File

@@ -0,0 +1,107 @@
-- Bazénové čerpadlo přes Shelly relé (Gen2 RPC).
-- (a) asset + 1min telemetrie vlastním pollingem (Shelly drží jen okamžitý stav a čítač
-- aenergy.total — historii si stavíme sami jako u ostatních zařízení, 60 s),
-- (b) ovládání on/off přes existující signal infrastrukturu (signal_def POOL_PUMP_ON,
-- route http_rest na Switch.Set — route je per site, seed v docs/04-modules/pool-shelly.md),
-- (c) plánovač: odložitelná zátěž s denní povinnou dobou filtrace (follow-up, viz docs).
-- ------------------------------------------------------------
-- Aktivum: bazénové čerpadlo za Shelly relé
-- ------------------------------------------------------------
create table ems.asset_pool_pump (
id serial primary key,
site_id int not null references ems.site (id),
code text not null,
manufacturer text,
model text,
endpoint_id int references ems.site_endpoint (id),
shelly_switch_id int not null default 0,
rated_power_w int not null,
min_run_min int not null default 15,
daily_runtime_min int not null default 240,
schedulable boolean not null default true,
notes text,
constraint uq_asset_pool_pump_site_code unique (site_id, code)
);
comment on table ems.asset_pool_pump is
'Bazénové (filtrační) čerpadlo spínané přes Shelly relé (Gen2 RPC, HTTP). Konstantní příkon, odložitelná zátěž s denní povinnou dobou běhu.';
comment on column ems.asset_pool_pump.site_id is
'Vazba na lokalitu.';
comment on column ems.asset_pool_pump.code is
'Kód aktiva, unikátní v rámci lokality. Příklad: pool-pump-01.';
comment on column ems.asset_pool_pump.endpoint_id is
'HTTP endpoint Shelly relé (ems.site_endpoint, endpoint_type http_api nebo shelly_http). Bez endpointu se čerpadlo nepolluje.';
comment on column ems.asset_pool_pump.shelly_switch_id is
'Id Switch komponenty v Shelly Gen2 RPC (Switch.GetStatus?id=N). U 1kanálových relé 0.';
comment on column ems.asset_pool_pump.rated_power_w is
'Jmenovitý příkon čerpadla ve W. Plánovač s ním počítá jako s konstantním výkonem při běhu.';
comment on column ems.asset_pool_pump.min_run_min is
'Minimální nepřerušený běh v minutách (ochrana čerpadla před krátkým cyklováním). Násobky 15min slotů.';
comment on column ems.asset_pool_pump.daily_runtime_min is
'Denní povinná doba filtrace v minutách — AKTUÁLNÍ sezónní hodnota (léto typ. více, zima méně / 0). Mění ji provozovatel ručně podle sezóny; plnohodnotný sezónní profil (tabulka měsíc → minuty) je follow-up, viz docs/04-modules/pool-shelly.md. 0 = filtrace vypnutá (mimo sezónu).';
comment on column ems.asset_pool_pump.schedulable is
'true = plánovač smí rozkládat běh do levných/přebytkových slotů; false = EMS jen měří, nespíná.';
-- ------------------------------------------------------------
-- 1min telemetrie (TimescaleDB hypertable)
-- ------------------------------------------------------------
create table ems.telemetry_pool_pump (
site_id int not null references ems.site (id),
pump_id int not null references ems.asset_pool_pump (id),
measured_at timestamptz not null,
is_on boolean,
power_w int,
energy_wh_total bigint,
primary key (pump_id, measured_at)
);
comment on table ems.telemetry_pool_pump is
'Telemetrie bazénového čerpadla ze Shelly relé (Gen2 Switch.GetStatus), 1min polling. TimescaleDB hypertable. Historie se staví výhradně tady — Shelly ji nedrží.';
comment on column ems.telemetry_pool_pump.site_id is
'Vazba na lokalitu.';
comment on column ems.telemetry_pool_pump.pump_id is
'Vazba na ems.asset_pool_pump.';
comment on column ems.telemetry_pool_pump.measured_at is
'Čas měření (UTC).';
comment on column ems.telemetry_pool_pump.is_on is
'Stav relé (Switch.GetStatus output).';
comment on column ems.telemetry_pool_pump.power_w is
'Okamžitý činný příkon ve W (Switch.GetStatus apower). NULL pokud model neměří výkon.';
comment on column ems.telemetry_pool_pump.energy_wh_total is
'Kumulativní čítač energie ve Wh (Switch.GetStatus aenergy.total). Po výpadku napájení Shelly může čítač začít znovu — energii za interval počítat jako kladnou diferenci.';
select create_hypertable(
'ems.telemetry_pool_pump',
'measured_at',
chunk_time_interval => interval '1 week',
if_not_exists => true
);
create index idx_telemetry_pool_pump_site_time
on ems.telemetry_pool_pump (site_id, measured_at desc);
-- ------------------------------------------------------------
-- Signál pro ovládání relé (route per site se seeduje provozně, šablona v docs)
-- ------------------------------------------------------------
insert into ems.signal_def (code, value_type, description)
values (
'POOL_PUMP_ON',
'bool',
'Požadovaný stav bazénového čerpadla (Shelly relé). Doručuje signal_service přes signal_route http_rest na Shelly Gen2 Switch.Set, readback verify přes Switch.GetStatus. Hodnotu nastavuje plánovač / operátor (fn_signal_enqueue_bool).'
)
on conflict (code) do nothing;

View File

@@ -0,0 +1,35 @@
-- Seed bazénového čerpadla home-01: Shelly Plug S Gen3 na 172.16.1.15 (VPN).
-- Telemetry-only start (schedulable=false): nejdřív měříme, ovládání signálem
-- POOL_PUMP_ON zapneme po ověření telemetrie. rated_power_w je odhad — skutečný
-- příkon ukáže telemetrie (apower), pak upřesnit.
insert into ems.site_endpoint (
site_id, endpoint_type, host, port, protocol, unit_id, enabled, notes
)
select s.id, 'shelly_http', '172.16.1.15', 80, 'http', null, true,
'Shelly Plug S Gen3 bazénové čerpadlo (Gen2+ RPC).'
from ems.site s
where s.code = 'home-01'
and not exists (
select 1 from ems.site_endpoint se
where se.site_id = s.id and se.host = '172.16.1.15'
);
insert into ems.asset_pool_pump (
site_id, code, manufacturer, model, endpoint_id, shelly_switch_id,
rated_power_w, min_run_min, daily_runtime_min, schedulable, notes
)
select
s.id, 'pool-pump-1', 'Shelly', 'Plug S Gen3', se.id, 0,
600, -- odhad; upřesnit dle telemetrie (Shelly apower)
15,
480, -- 8 h/den (letní filtrace); zimní profil = follow-up
false, -- telemetry-only; ovládání po ověření
'Bazénové čerpadlo přes Shelly Plug S Gen3. Ovládání: signal POOL_PUMP_ON (fn_signal_enqueue_bool), zatím vypnuto.'
from ems.site s
join ems.site_endpoint se on se.site_id = s.id and se.host = '172.16.1.15'
where s.code = 'home-01'
and not exists (
select 1 from ems.asset_pool_pump p
where p.site_id = s.id and p.code = 'pool-pump-1'
);

View File

@@ -0,0 +1,63 @@
-- EV spotřební forecast: pozorování (odometer+SoC při příjezdu/odjezdu, auto je
-- vzhůru — žádné buzení navíc), jízdy, statistiky per den v týdnu. Cíl: target
-- SoC a deadline session z reálného týdenního rytmu (pondělí služebka ~150 km
-- → skoro plná; konec týdne míň; víkend = levné sloty na přípravu pondělka).
-- Zapnutí per vozidlo: target_soc_forecast_enabled (default false = sbírá se,
-- session jedou na defaultech).
alter table ems.asset_vehicle
add column if not exists target_soc_forecast_enabled boolean not null default false,
add column if not exists min_target_soc_pct numeric(5,2) not null default 30.0;
comment on column ems.asset_vehicle.target_soc_forecast_enabled is
'true = target SoC + deadline session z ev_usage_stats (fn_ev_required_soc); false = default_target_soc_pct/default_deadline_hour. Forecast vyžaduje >= 4 vzorky pro daný den v týdnu, jinak fallback.';
comment on column ems.asset_vehicle.min_target_soc_pct is
'Komfortní spodní mez forecast targetu (%). Forecast smí jít pod default_target_soc_pct (např. pátek), ne pod tuto mez.';
create table ems.ev_vehicle_obs (
id bigserial primary key,
site_id int not null references ems.site (id),
vehicle_id int not null references ems.asset_vehicle (id),
observed_at timestamptz not null default now(),
trigger text not null check (trigger in ('arrival', 'departure', 'manual')),
odometer_km numeric(10, 1),
soc_pct numeric(5, 2),
charging_state text
);
create index idx_ev_vehicle_obs_vehicle_time
on ems.ev_vehicle_obs (vehicle_id, observed_at desc);
comment on table ems.ev_vehicle_obs is
'Pozorování vozidla z API výrobce (Tesla Fleet) při příjezdu/odjezdu — auto je v těch okamžicích vzhůru, čtení nebudí. Zdroj pro ev_trip.';
create table ems.ev_trip (
id serial primary key,
vehicle_id int not null references ems.asset_vehicle (id),
departure_obs_id bigint not null references ems.ev_vehicle_obs (id),
arrival_obs_id bigint not null references ems.ev_vehicle_obs (id),
departed_at timestamptz not null,
arrived_at timestamptz not null,
km numeric(8, 1),
kwh_est numeric(7, 2),
charged_away boolean not null default false,
constraint uq_ev_trip_departure unique (departure_obs_id)
);
comment on table ems.ev_trip is
'Jízda = pár odjezd→příjezd: km z odometru, kWh z ΔSoC × kapacita. charged_away = SoC po cestě vzrostlo (nabíjení mimo dům) — kWh nevypovídá, vyloučit ze statistik.';
create table ems.ev_usage_stats (
vehicle_id int not null references ems.asset_vehicle (id),
day_of_week int not null check (day_of_week between 0 and 6),
avg_day_kwh numeric(7, 2),
stddev_day_kwh numeric(7, 2),
avg_day_km numeric(8, 1),
avg_departure_hour numeric(4, 2),
sample_count int not null default 0,
last_updated timestamptz not null default now(),
primary key (vehicle_id, day_of_week)
);
comment on table ems.ev_usage_stats is
'Týdenní rytmus vozidla per den v týdnu (0=neděle, Europe/Prague): průměrná denní spotřeba jízdou (kWh), km, typická hodina prvního odjezdu. Plní fn_update_ev_usage_stats (job 00:50). Vstup fn_ev_required_soc / fn_ev_expected_departure.';

View File

@@ -0,0 +1,13 @@
-- PV-risk front-load: prémie za držení energie DŘÍV uvnitř okna sell<0.
-- Solver je k načasování nabíjení v neg okně jinak indiferentní (PV je zdarma
-- kdykoliv) — odložené nabití ale spoléhá na predikci (večerní mrak = drahý
-- nákup). Malá prémie (Kč/kWh/slot) rozbije indiferenci směrem k "nabít plným
-- výkonem hned" (v1 rampa), ale nikdy nepřebije skutečné ceny.
-- 0.01 → držení 1 kWh o 6 h dřív = 0.24 Kč; 0 = vypnuto.
alter table ems.asset_battery
add column if not exists planner_pv_risk_frontload_czk_kwh numeric(6, 4)
not null default 0.01;
comment on column ems.asset_battery.planner_pv_risk_frontload_czk_kwh is
'v2: prémie za držení energie dřív v okně sell<0 (Kč za kWh a 15min slot). Ocenění rizika chyby PV predikce — front-load nabíjení. 0 = vypnuto.';

View File

@@ -0,0 +1,13 @@
-- Denní SoC bezpečnostní rampa ve v2: deficit pod safety_soc_target_wh
-- (R__063: rampa reserve→reserve+noční potřeba, 619 h) platí za každý slot
-- "nájem" = buy_cena × faktor. Ráno tak baterie nejdřív dotáhne na ~reserve
-- (KV1/BA81 30 %) a teprve pak prodává — nenadálý odběr/mrak nekupuje za
-- draho ze sítě. Extrémní sell špička smí deficit racionálně podstoupit.
-- 0 = vypnuto; default 0.05 (deficit 1 kWh držený 4 h při buy 6 Kč ≈ 4.8 Kč).
alter table ems.asset_battery
add column if not exists planner_safety_soc_risk_factor numeric(5, 3)
not null default 0.05;
comment on column ems.asset_battery.planner_safety_soc_risk_factor is
'v2: podíl buy ceny účtovaný za KAŽDÝ 15min slot deficitu pod safety_soc_target_wh (denní rampa z R__063). Ocenění rizika nenadálého odběru při slabé predikci. 0 = vypnuto.';

View File

@@ -0,0 +1,55 @@
-- Bazén: sezóna, délka filtrace dle teploty vody, čtení čidel z Loxone.
--
-- Sezóna: přepínač = existující asset_pool_pump.schedulable (true = plánovač
-- řídí; konec sezóny -> false: telemetrie běží dál, signály/solver ne).
-- Viz docs/04-modules/pool-shelly.md § Sezóna.
--
-- Teplotní funkce (slaná voda, chlorinátor potřebuje průtok; teplejší voda =
-- delší filtrace): runtime_min(t) = clamp(base + per_c × (t ref), min, max).
-- Defaulty pro 30 m³ / 8 m³/h (obrátka 3.75 h): 20 °C → 4.5 h, 26 °C → 7.5 h,
-- 28 °C → 8.5 h, strop 10 h. Bez čidla / starého měření → fallback
-- daily_runtime_min. Vše per čerpadlo v DB (pravidlo 16).
create table ems.loxone_sensor (
id serial primary key,
site_id int not null references ems.site (id),
code text not null,
loxone_name text not null,
unit text,
enabled boolean not null default true,
notes text,
constraint uq_loxone_sensor_site_code unique (site_id, code)
);
comment on table ems.loxone_sensor is
'Čidla čtená z Loxone Miniserveru (GET /jdev/sps/io/<loxone_name>/state přes loxone_http endpoint site). Telemetrie 60 s do telemetry_loxone_sensor.';
create table ems.telemetry_loxone_sensor (
sensor_id int not null references ems.loxone_sensor (id),
measured_at timestamptz not null,
value numeric(10, 2),
primary key (sensor_id, measured_at)
);
select create_hypertable(
'ems.telemetry_loxone_sensor',
'measured_at',
chunk_time_interval => interval '1 week',
if_not_exists => true
);
comment on table ems.telemetry_loxone_sensor is
'1min hodnoty Loxone čidel (teplota bazénu, akumulační nádrže, ...).';
alter table ems.asset_pool_pump
add column if not exists water_temp_sensor_id int references ems.loxone_sensor (id),
add column if not exists runtime_ref_temp_c numeric(4, 1) not null default 20.0,
add column if not exists runtime_base_min int not null default 270,
add column if not exists runtime_min_per_c int not null default 30,
add column if not exists runtime_min_min int not null default 180,
add column if not exists runtime_max_min int not null default 600;
comment on column ems.asset_pool_pump.water_temp_sensor_id is
'Loxone čidlo teploty vody; NULL = teplotní funkce vypnutá (fallback daily_runtime_min).';
comment on column ems.asset_pool_pump.runtime_base_min is
'Minuty filtrace/den při runtime_ref_temp_c; nad ní +runtime_min_per_c za °C, clamp [runtime_min_min, runtime_max_min].';

View File

@@ -0,0 +1,10 @@
-- Tesla Model Y 2025 Standard RWD (LFP): kapacita ~62.5 kWh (v seedu bylo 75 =
-- hodnota LR varianty) a default cíl 100 % — LFP chemie pravidelné nabití na
-- 100 % vyžaduje (balancování), žádná degradační penalizace jako u NMC.
-- Kapacita vstupuje do energy_needed (target soc) × kWh a do EV usage stats.
update ems.asset_vehicle
set battery_capacity_kwh = 62.5,
default_target_soc_pct = 100
where code = 'tesla-my'
and site_id = (select id from ems.site where code = 'home-01');

View File

@@ -0,0 +1,13 @@
-- Oportunistické EV nabíjení („měkký cíl"): nad tvrdý target smí auto nasát
-- přebytky až do 100 %, oceněné hodnotou BUDOUCÍHO ušetřeného nabíjení
-- (~1 Kč/kWh — budoucí nabíjení je stejně v levných slotech). Uplatní se
-- hlavně při záporných cenách / plné domácí baterce (lepší než curtail);
-- běžné ceny ho nezaplatí. 0 = vypnuto. Víkend: páteční malý tvrdý cíl
-- + víkendové negativní ceny → auto se doplní samo, bez speciální logiky.
alter table ems.asset_vehicle
add column if not exists opportunistic_value_czk_kwh numeric(6, 3)
not null default 1.0;
comment on column ems.asset_vehicle.opportunistic_value_czk_kwh is
'v2: hodnota kWh nabité NAD target session (do 100 %) = ušetřené budoucí nabíjení. Solver ji zaplatí jen při velmi levné/záporné energii. 0 = vypnuto.';

View File

@@ -0,0 +1,22 @@
-- Presence vozidla (Tesla location scope): kde auto je, kdy bývá doma.
-- Zdroj: levný poll /vehicles (state, NEbudí) + při online location_data.
-- Účel: (a) notifikace "auto doma a nepíchlé + svítí přebytek → píchni ho",
-- (b) dostupnostní statistika per DOW×hodina pro plánovač (maska ev_connected
-- a zreálnění oportunistické hodnoty) — follow-up nad těmito daty.
create table ems.ev_presence_obs (
id bigserial primary key,
vehicle_id int not null references ems.asset_vehicle (id),
observed_at timestamptz not null default now(),
api_state text, -- online / asleep / offline (z /vehicles, bez buzení)
at_home boolean, -- null = poloha neznámá (asleep)
distance_m int,
charging_state text, -- Disconnected / Stopped / Charging…
shift_state text
);
create index idx_ev_presence_obs_vehicle_time
on ems.ev_presence_obs (vehicle_id, observed_at desc);
comment on table ems.ev_presence_obs is
'Pozorování přítomnosti vozidla (geofence vs GPS site). Poll ~5 min, poloha jen když je auto vzhůru (nebudí). Vstup pro "píchni auto" notifikace a budoucí dostupnostní statistiku.';

View File

@@ -0,0 +1,21 @@
-- Samsung TČ (EHS) přes Modbus interface MIM-B19N(T): skutečný RS485→TCP
-- převodník (Waveshare RS485 TO POE ETH (B)) na 172.16.1.17 nahrazuje
-- placeholder 192.168.1.103 ze seedu. MIM = Modbus RTU slave, 9600 8E1,
-- adresa dle DIP/rotary (zde 1). Registry: docs/04-modules/modbus-registers-mim-b19n.md.
update ems.site_endpoint e
set host = '172.16.1.17',
port = 502,
notes = 'Waveshare RS485 TO POE ETH (B) pro Samsung EHS přes MIM-B19N(T). Sériová linka 9600 8E1 (parita EVEN!), Modbus TCP server :502, unit_id = adresa MIM dle DIP (1).'
where e.id = (
select hp.endpoint_id
from ems.asset_heat_pump hp
join ems.site s on s.id = hp.site_id
where s.code = 'home-01'
);
alter table ems.telemetry_heat_pump
add column if not exists room_temp_c numeric(5,2);
comment on column ems.telemetry_heat_pump.room_temp_c is
'Prostorová teplota hlášená vnitřní jednotkou (MIM reg base+9, °C×10). Vstup budoucího termálního modelu domu.';

View File

@@ -0,0 +1,27 @@
-- Wallboxy TeltoCharge: skutečný RS485→TCP převodník (Waveshare) 172.16.1.16
-- nahrazuje placeholdery 192.168.1.101/.102. Sdílená RS485 sběrnice
-- (9600 8N1 dle nastavení převodníku 2026-06-12): WB1 = unit 1, WB2 = unit 2,
-- výhledově Chint elektroměr = unit 3. Modbus na wallboxech nutno povolit
-- v Teltonika aplikaci (adresa + baud shodné s převodníkem).
update ems.site_endpoint e
set host = '172.16.1.16',
port = 502,
unit_id = 1,
notes = 'Waveshare RS485 TO POE ETH (B) 172.16.1.16 — sdílená sběrnice wallboxů (9600 8N1, Modbus TCP↔RTU). TeltoCharge #1 = unit 1.'
where e.id = (
select ec.endpoint_id from ems.asset_ev_charger ec
join ems.site s on s.id = ec.site_id
where s.code = 'home-01' and ec.code = 'ev-charger-1'
);
update ems.site_endpoint e
set host = '172.16.1.16',
port = 502,
unit_id = 2,
notes = 'Waveshare RS485 TO POE ETH (B) 172.16.1.16 — sdílená sběrnice wallboxů (9600 8N1, Modbus TCP↔RTU). TeltoCharge #2 = unit 2.'
where e.id = (
select ec.endpoint_id from ems.asset_ev_charger ec
join ems.site s on s.id = ec.site_id
where s.code = 'home-01' and ec.code = 'ev-charger-2'
);

Some files were not shown because too many files have changed in this diff Show More